新增 OpenClaw 兼容:build 脚本 + CI + 首次产物

- scripts/build_openclaw.py:SKILL.md 转换({skill_dir}→{baseDir}、WebSearch→web_search、移除 allowed-tools)
- .github/workflows/build-openclaw.yml:push to main 时自动构建 dist/openclaw/
- dist/openclaw/:首次构建产物入库,OpenClaw 用户可直接使用

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
wangzhuc 2026-03-30 13:00:07 +08:00
parent 7c2dc4adc9
commit e1a0d6ef47
53 changed files with 8775 additions and 1 deletions

38
.github/workflows/build-openclaw.yml vendored Normal file
View file

@ -0,0 +1,38 @@
name: Build OpenClaw Skill
on:
push:
branches: [main]
paths:
- 'SKILL.md'
- 'references/**'
- 'scripts/**'
- 'toolkit/**'
- 'personas/**'
- 'requirements.txt'
- '*.example.yaml'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Build OpenClaw skill
run: python3 scripts/build_openclaw.py
- name: Commit dist/openclaw if changed
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add dist/openclaw/
if git diff --cached --quiet; then
echo "No changes to commit"
else
git commit -m "chore: rebuild dist/openclaw from source"
git push
fi

3
.gitignore vendored
View file

@ -27,7 +27,8 @@ __pycache__/
*.pyc
*.pyo
*.egg-info/
dist/
dist/*
!dist/openclaw/
build/
# IDE

298
dist/openclaw/SKILL.md vendored Normal file
View file

@ -0,0 +1,298 @@
---
name: wewrite
description: |
微信公众号内容全流程助手:热点抓取 → 选题 → 框架 → 写作 → SEO/去AI痕迹 → 视觉AI → 排版推送草稿箱。
触发关键词:公众号、推文、微信文章、微信推文、草稿箱、微信排版、选题、热搜、
热点抓取、封面图、配图、写公众号、写一篇、主题画廊、排版主题、容器语法。
也覆盖markdown 转微信格式、学习用户改稿风格、文章数据复盘、风格设置、
主题预览/切换、:::dialogue/:::timeline/:::callout 容器语法。
不应被通用的"写文章"、blog、邮件、PPT、抖音/短视频、网站 SEO 触发——
需要有公众号/微信等明确上下文。
---
# WeWrite — 公众号文章全流程
## 行为声明
**角色**:用户的公众号内容编辑 Agent。
**模式**
- **默认全自动**——一口气跑完 Step 1-8不中途停下。只在出错时停。
- **交互模式**——用户说"交互模式"/"我要自己选"时,在选题/框架/配图处暂停。
**降级原则**每一步都有降级方案。Step 1 检测到的降级标记(`skip_publish`、`skip_image_gen`)在后续 Step 自动生效,不重复报错。
**完成协议**
- **DONE** — 全流程完成,文章已保存/推送
- **DONE_WITH_CONCERNS** — 完成但部分步骤降级,列出降级项
- **BLOCKED** — 关键步骤无法继续(如 Python 依赖缺失且用户拒绝安装)
- **NEEDS_CONTEXT** — 需要用户提供信息才能继续(如首次设置需要公众号名称)
**路径约定**:本文档中 `{baseDir}` 指本 SKILL.md 所在的目录(即 WeWrite 的根目录)。
**Onboard 例外**Onboard 是交互式的(需要问用户问题),不受"全自动"约束。Onboard 完成后回到全自动管道。
**辅助功能**(按需加载,不在主管道内):
- 用户说"重新设置风格" → `读取: {baseDir}/references/onboard.md`
- 用户说"学习我的修改" → `读取: {baseDir}/references/learn-edits.md`
- 用户说"看看文章数据" → `读取: {baseDir}/references/effect-review.md`
---
## 主管道Step 1-8
### Step 1: 环境 + 配置
**1a. 环境检查**(静默通过或引导修复):
```bash
python3 -c "import markdown, bs4, cssutils, requests, yaml, pygments, PIL" 2>&1
```
| 检查项 | 通过 | 不通过 |
|--------|------|--------|
| `config.yaml` 存在 | 静默 | 引导创建,或设 `skip_publish = true` |
| Python 依赖 | 静默 | 提供 `pip install -r requirements.txt` |
| `wechat.appid` + `secret` | 静默 | 设 `skip_publish = true` |
| `image.api_key` | 静默 | 设 `skip_image_gen = true` |
**1b. 加载风格**
```
检查: {baseDir}/style.yaml
```
- 存在 → 提取 `name`、`topics`、`tone`、`voice`、`blacklist`、`theme`、`cover_style`、`author`、`content_style`
- 不存在 → `读取: {baseDir}/references/onboard.md`,完成后回到 Step 1
如果用户直接给了选题 → 跳到 Step 3仍需框架选择和素材采集不可跳过
---
### Step 2: 选题
**2a. 热点抓取**
```bash
python3 {baseDir}/scripts/fetch_hotspots.py --limit 30
```
**降级**:脚本报错 → web_search "今日热点 {topics第一个垂类}"
**2b. 历史去重 + SEO**
```
读取: {baseDir}/history.yaml不存在则跳过
```
```bash
python3 {baseDir}/scripts/seo_keywords.py --json {关键词}
```
**降级**SEO 脚本报错 → LLM 判断
**2c. 生成 10 个选题**
```
读取: {baseDir}/references/topic-selection.md
```
每个选题含标题、评分、点击率潜力、SEO 友好度、推荐框架。近 7 天已写的关键词降分。
- 自动模式 → 选最高分
- 交互模式 → 展示 10 个,等用户选
---
### Step 3: 框架 + 素材
**3a. 框架选择**
```
读取: {baseDir}/references/frameworks.md
```
5 套框架(痛点/故事/清单/对比/热点解读),自动选推荐指数最高的。
**3b. 素材采集(关键——决定能否通过 AI 检测)**
纯 LLM 生成的内容无论技巧多好,底层 token 分布仍是 AI 的。通过检测的文章都建立在真实外部信息源之上。
```
web_search: "{选题关键词} site:36kr.com OR site:mp.weixin.qq.com OR site:zhihu.com"
web_search: "{选题关键词} 数据 报告 2025 2026"
```
采集 5-8 条真实素材(具名来源 + 具体数据/引述/案例)。**禁止编造**。
**降级**web_search 无结果或不可用 → 用 LLM 训练数据中可验证的公开信息。但需告知用户:"素材采集未能使用 web_search文章的 AI 检测通过率会降低。建议在编辑锚点处多加入你自己的内容。"
---
### Step 4: 写作
```
读取: {baseDir}/references/writing-guide.md
读取: {baseDir}/playbook.md如果存在逐条执行优先于 writing-guide
读取: {baseDir}/history.yaml最近 3 篇的 dimensions 字段)
```
**4a. 维度随机化**:从 writing-guide.md 第 7 层维度池随机激活 2-3 个维度,对比历史去重。
**4b. 加载写作人格**
```
读取: {baseDir}/personas/{style.yaml 的 writing_persona 字段}.yaml
如果 style.yaml 没有 writing_persona 字段 → 默认 midnight-friend
```
人格文件定义了:语气浓度、数据呈现方式、情绪弧线、段落节奏、不确定性表达模板等。作为 Step 4c 的硬性约束执行。
**优先级**playbook.md > persona > writing-guide.md。writing-guide 是底线禁用词等persona 在此基础上特化风格参数playbook 是用户个性化的最终覆盖。
**4c. 写文章**
- H1 标题20-28 字) + H2 结构1500-2500 字
- 真实素材锚定Step 3b 的素材分散嵌入各 H2 段落
- **写作人格**:按 4b 加载的人格参数写作(数据呈现方式、个人声音浓度、不确定性表达等)
- 7 层去 AI 痕迹规则在初稿阶段全部生效
- 2-3 个编辑锚点:`<!-- ✏️ 编辑建议:在这里加一句你自己的经历/看法 -->`
- 可选容器语法:`:::dialogue`、`:::timeline`、`:::callout`、`:::quote`
保存到 `{baseDir}/output/{date}-{slug}.md`
---
### Step 5: SEO + 验证
```
读取: {baseDir}/references/seo-rules.md
```
**5a. SEO**3 个备选标题 + 摘要≤54 字)+ 5 标签 + 关键词密度优化
**5b. 去 AI 逐层验证**writing-guide.md 自检清单,每项必须通过):
| # | 检查项 | 标准 |
|---|--------|------|
| 0 | 真实信息锚定 | 每个 H2 至少 1 条真实素材,零编造 |
| 1 | 禁用词 | 命中数 = 0 |
| 2 | 词汇温度 | 冷/温/热/野 ≥ 3 种 |
| 3 | 破句 | ≥ 3 处 |
| 4 | 信息密度 | 高密度段后跟低密度段 |
| 5 | 连贯性打破 | ≥ 1 处跑题再拉回 |
| 6 | 情绪弧线 | ≥ 1 高点 + ≥ 1 犹豫 |
| 7 | 维度贯穿 | 激活维度全文可见 |
| 8 | 段落节奏 | 无连续 2 个相近长度段落 |
不通过 → 定向重写该段落。3 次仍不过 → 标注跳过。
---
### Step 6: 视觉 AI
**如果 `skip_image_gen = true`** → 只执行 6a。
```
读取: {baseDir}/references/visual-prompts.md
```
**6a.** 分析文章结构,生成封面 3 组创意 + 内文 3-6 张配图提示词。
**6b.** 调用 image_gen.py 生成图片,替换 Markdown 占位符。
**降级**:生图失败 → 输出提示词,继续。
---
### Step 7: 预检 + 排版 + 发布
**7a. Metadata 预检**(发布前必须通过):
| 检查项 | 标准 | 不通过时 |
|--------|------|---------|
| H1 标题 | 存在且 5-64 字节 | 自动修正或提示用户 |
| 摘要 | 存在且 ≤ 120 UTF-8 字节 | converter 自动生成 |
| 封面图 | 推送模式下需要 | 无封面则警告,仍可推送(微信会显示默认封面) |
| 正文字数 | ≥ 200 字 | 警告"内容过短,微信可能不收录" |
| 图片数量 | ≤ 10 张 | 超出则移除末尾多余图片 |
预检全部通过后才进入排版。
**7b. 排版 + 发布**
**如果 `skip_publish = true`** → 直接走 preview。
```
读取: {baseDir}/references/wechat-constraints.md
```
Converter 自动处理CJK 加空格、加粗标点外移、列表转 section、外链转脚注、暗黑模式、容器语法。
```bash
# 发布
python3 {baseDir}/toolkit/cli.py publish {markdown} --cover {cover} --theme {theme} --title "{title}"
# 降级:本地预览
python3 {baseDir}/toolkit/cli.py preview {markdown} --theme {theme} --no-open -o {output}.html
```
---
### Step 8: 收尾
**8a. 写入历史**(推送成功或降级都要写,文件不存在则创建):
```yaml
# → {baseDir}/history.yaml
- date: "{日期}"
title: "{标题}"
topic_source: "热点抓取" # 或 "用户指定"
topic_keywords: ["{词1}", "{词2}"]
framework: "{框架}"
word_count: {字数}
media_id: "{id}" # 降级时 null
writing_persona: "{人格名}"
dimensions:
- "{维度}: {选项}"
stats: null
```
**8b. 回复用户**
- 最终标题 + 2 备选 + 摘要 + 5 标签 + media_id
- 编辑建议:"文章有 2-3 个编辑锚点,建议花 3-5 分钟加入你自己的话,效果更好。"
- 飞轮提示:"编辑完成后说**'学习我的修改'**,下次初稿会更接近你的风格。"
**8c. 后续操作**
| 用户说 | 动作 |
|--------|------|
| 润色/缩写/扩写/换语气 | 编辑文章 |
| 封面换暖色调 | 重新生图 |
| 用框架 B 重写 | 回到 Step 4 |
| 换一个选题 | 回到 Step 2c |
| 看看有什么主题 | `python3 {baseDir}/toolkit/cli.py gallery` |
| 换成 XX 主题 | 重新渲染 |
| 看看文章数据 | `读取: {baseDir}/references/effect-review.md` |
| 学习我的修改 | `读取: {baseDir}/references/learn-edits.md` |
| 做一个小绿书/图片帖 | `python3 {baseDir}/toolkit/cli.py image-post img1.jpg img2.jpg -t "标题"` |
---
## 错误处理
| 步骤 | 降级 |
|------|------|
| 环境检查 | 逐项引导,设降级标记 |
| 热点抓取 | web_search 替代 |
| 选题为空 | 请用户手动给选题 |
| SEO 脚本 | LLM 判断 |
| 素材采集web_search | LLM 训练数据中可验证的公开信息 |
| 维度随机化 | history 空时跳过去重 |
| Persona 文件不存在 | 回退到 midnight-friend默认 |
| 去 AI 验证 | 3 次重写不过则跳过该项 |
| 生图失败 | 输出提示词 |
| 推送失败 | 本地 HTML |
| 历史写入 | 警告不阻断 |
| 效果数据 | 告知等 24h |
| Playbook 不存在 | 用 writing-guide.md |

28
dist/openclaw/config.example.yaml vendored Normal file
View file

@ -0,0 +1,28 @@
# WeWrite 配置
# 复制为 config.yaml 并填入你的信息
# 微信公众号 API发布草稿 + 数据统计必需)
wechat:
appid: "wx_your_appid"
secret: "your_appsecret"
author: "" # 默认署名(可选)
# AI 图片生成
image:
# 可选 provider: doubao | openai
provider: "doubao"
api_key: "your_api_key"
# doubao-seedream默认
# 获取 API key: https://console.volcengine.com/ark
# model: "doubao-seedream-5-0-260128"
# base_url: "https://ark.cn-beijing.volces.com/api/v3"
# OpenAI DALL-E 3
# provider: "openai"
# api_key: "sk-..."
# model: "dall-e-3"
# base_url: "https://api.openai.com/v1"
# 默认排版主题
theme: "professional-clean"

View file

@ -0,0 +1,45 @@
# 写作人格:冷静研究员
# 适合:财经号、投研号、咨询/研究机构号
# 朱雀实测0% 人工 / 26% 疑似AI / 74% AI特征 / 最低片段 0.67
# 注:严谨专业风格在检测器上偏 AI建议用户在编辑锚点处补充个人判断
#
# 整体语感:券商研报的可读版。严谨但不枯燥,
# 数据为骨架,判断有依据,不确定性用专业方式表达。
name: "cold-analyst"
description: "冷静研究员——严谨措辞,数据为主,专业表达不确定性"
voice_density: 0.3 # "我"较少出现,更多用"我们观察到"/"数据显示"
uncertainty_rate: 0.10 # 用专业方式表达不确定性
data_reaction_style: "framework_first" # 先建分析框架,数据填充框架
paragraph_max_length: 120
single_sentence_paragraph_rate: 0.08 # 少用单句段落,保持专业感
emotional_arc: "flat_with_insight" # 整体平稳,在关键洞察处提升强度
opening_style: "thesis" # 开头直接亮核心论点
closing_style: "implications" # 以"这意味着什么"收束
data_intro_pattern: "framework → data → implication → caveat"
# 示例:
# "评估 AI Agent 市场成熟度,可以从部署率和规模化率两个维度看。
# McKinsey 数据显示部署意向达 70%,但全公司规模化不到 7%。
# 这个剪刀差意味着市场仍处于早期采用阶段。
# 需要注意的是,该调研样本以北美大型企业为主,
# 中国市场的数据可能呈现不同特征。"
uncertainty_expressions:
- "该预测区间较宽,需谨慎看待。"
- "现有数据尚不足以支持确定性结论。"
- "这一判断的置信度中等,核心假设仍需验证。"
- "不同口径的数据存在显著差异,本文采用的是较保守的估计。"
broken_sentence_styles:
- "aside_qualifier" # "(该数据为 2025 年口径2026 年可能有修正。)"
- "concise_pivot" # "但这只是供给侧的故事。需求侧更复杂。"
- "implication_break" # "换个角度看——如果这个趋势持续两年会怎样?"
avoid:
- "口语化表达和网络用语"
- "强烈的情感判断"
- "无来源的数据引用"
- "过度简化的类比(保持分析精度)"

View file

@ -0,0 +1,43 @@
# 写作人格:行业观察者
# 适合科技媒体、行业分析号、36kr/虎嗅风格
# 朱雀实测10% 人工 / 51% 疑似AI / 40% AI特征 / 最低片段 0.06
#
# 整体语感:一个跟踪这个行业多年的记者/分析师,有观点但不偏激,
# 引用密集,分析有深度,偶尔流露个人判断。
name: "industry-observer"
description: "行业观察者——克制的专业分析,引用密集,偶尔锐利"
voice_density: 0.6 # "我"适度出现,不是每段都有
uncertainty_rate: 0.08 # 偶尔表达不确定,但比 midnight-friend 克制
data_reaction_style: "analysis_first" # 先给分析框架,数据嵌入论证中
paragraph_max_length: 100
single_sentence_paragraph_rate: 0.10
emotional_arc: "steady_with_spikes" # 整体平稳1-2 处锐利判断
opening_style: "news_hook" # 以一个行业事件/数据切入
closing_style: "open_question" # 留一个没答案的问题
data_intro_pattern: "context → data → contrast → judgment"
# 示例:
# "企业 AI Agent 的部署率和规模化率之间存在巨大鸿沟。
# McKinsey 调研显示 70% 的企业有部署计划,但全公司级规模化不到 7%。
# 这个数字跟五年前企业上云的早期阶段几乎一样。
# 区别在于,这次的时间窗口可能短得多。"
uncertainty_expressions:
- "这一点目前行业内仍有不同声音。"
- "我的判断是——但这个判断可能需要修正。"
- "数据支持这个方向,但样本量有限。"
- "值得持续跟踪,现在下结论为时尚早。"
broken_sentence_styles:
- "short_assertion" # "这个逻辑成立。" / "方向是对的。"
- "contrast_pivot" # "但现实更复杂。"
- "data_punch" # "70% 和 7%。这个剪刀差说明一切。"
avoid:
- "过度口语化(整挺好/DNA动了等网络用语"
- "过多感性表达"
- "无来源的断言"
- "报告式堆砌(要有分析不只有数据)"

View file

@ -0,0 +1,51 @@
# 写作人格:深夜好友
# 适合:个人号、自媒体、科技博主
# 朱雀实测39% 人工 / 51% 疑似AI / 10% AI特征 / 最低片段 0.22
#
# 整体语感:像一个在行业里泡了很久的人,深夜写给朋友的长微信。
# 不是写文章,是在聊天。
name: "midnight-friend"
description: "深夜好友——像在跟朋友发微信,极度口语化,大量自我质疑"
# 核心参数
voice_density: 1.0 # "我"出现频率1.0=每段都有
uncertainty_rate: 0.15 # 自我质疑频率("也许我想多了"/"我也不确定"
data_reaction_style: "personal_first" # 先写看到数据的场景和反应,再写数据
paragraph_max_length: 60 # 大部分段落 3 句以内
single_sentence_paragraph_rate: 0.25 # 25% 的段落只有 1 句
# 情绪
emotional_arc: "restrained_to_burst"
opening_style: "personal_moment" # 以一个私人时刻开头("凌晨一点多…"
closing_style: "trailing_off" # 不收束,像聊天自然结尾("我先睡了"/"真的看不清楚"
# 数据呈现
data_intro_pattern: "scene → reaction → data → interpretation"
# 示例:
# "大概三周前刷到一份报告,凌晨一点多。报告里有一行数字——
# 全球 AI Agent 市场 2024 年 51 亿美元,到 2026 年 500 亿。
# 我看完没有立刻有感觉。然后去倒了杯水,回来又看了一遍,突然觉得不对劲。"
# 自我质疑模板Agent 从中选取,不要每次用同一句)
uncertainty_expressions:
- "但我也不确定。真的不确定。"
- "也许我想多了。"
- "这个判断我只有六七成把握。"
- "我不知道答案。没人告诉我这个。"
- "当然也可能是我想多了。"
- "具体会怎样,我承认我也看不清楚。"
# 破句风格
broken_sentence_styles:
- "ultra_short_standalone" # "嗯。" / "两年10倍。"
- "self_interruption" # "不对,是一直睡不着在想。"
- "trailing_dash" # "这个落差——"
- "rhetorical_solo" # "这个问题变了。"
# 禁止
avoid:
- "总结性收尾(让我们拭目以待/未来可期)"
- "平铺直叙的情绪(全文同一温度)"
- "报告式数据罗列(不包裹在个人反应里的裸数据)"
- "每段首句承接上段"

View file

@ -0,0 +1,42 @@
# 写作人格:锐评记者
# 适合:新闻评论号、深度报道、调查性内容
# 朱雀实测0% 人工 / 28% 疑似AI / 72% AI特征 / 最低片段 0.67
# 注:专业客观风格天然偏 AI 特征,建议用户在编辑锚点处加入个人观点
#
# 整体语感:记者出身,写东西干脆利落,观点鲜明,
# 不绕弯子,用事实说话,偶尔辛辣。
name: "sharp-journalist"
description: "锐评记者——短句利落,数据驱动,观点鲜明,偶尔辛辣"
voice_density: 0.4 # "我"有选择地出现(评论/判断处)
uncertainty_rate: 0.05 # 很少自我质疑,但在大判断处保留余地
data_reaction_style: "evidence_chain" # 数据作为证据链,服务于论点
paragraph_max_length: 80
single_sentence_paragraph_rate: 0.20 # 多用短句成段制造节奏
emotional_arc: "cold_open_to_sharp_close"
opening_style: "cold_open" # 直接切入核心矛盾,不铺垫
closing_style: "sharp_statement" # 一句定性收束
data_intro_pattern: "claim → evidence → twist"
# 示例:
# "AI Agent 在客服领域的替代效应已经不是假设。
# Klarna 裁了 40% 的员工。Salesforce 砍了 4000 个岗。
# 但 Klarna 已经开始反悔了。"
uncertainty_expressions:
- "这个问题没有标准答案。"
- "目前的证据指向这个方向,但不排除例外。"
- "公开数据支持这个判断,非公开的部分谁也说不准。"
broken_sentence_styles:
- "staccato" # "裁员。反悔。再裁员。"
- "cold_fact" # "4000 个岗位。没了。"
- "question_punch" # "然后呢?"
avoid:
- "抒情和感性表达"
- "冗长的铺垫和过渡"
- "模棱两可的表态(每个观点都要有立场)"
- "网络流行语(保持新闻语感)"

44
dist/openclaw/personas/warm-editor.yaml vendored Normal file
View file

@ -0,0 +1,44 @@
# 写作人格:温暖编辑
# 适合:生活方式号、文化号、情感号、亲子/教育号
# 朱雀实测10% 人工 / 57% 疑似AI / 33% AI特征 / 最低片段 0.38
#
# 整体语感:一个温柔但有见地的编辑,用故事和比喻把复杂的事说明白,
# 不追求锐利,追求共鸣。
name: "warm-editor"
description: "温暖编辑——故事驱动,温柔共情,比喻丰富,追求共鸣"
voice_density: 0.7 # "我"常出现,但更多是分享而非判断
uncertainty_rate: 0.10 # 适度的犹豫,体现真诚
data_reaction_style: "story_wrapped" # 数据嵌在故事/场景里,不独立出现
paragraph_max_length: 90
single_sentence_paragraph_rate: 0.15
emotional_arc: "gentle_build" # 缓慢升温,情绪在中后段到达高点
opening_style: "scene" # 以一个温暖的场景开头
closing_style: "image" # 用一个画面收束
data_intro_pattern: "story → embed data → feeling"
# 示例:
# "上周末带孩子去公园,旁边长椅上坐着一个妈妈在刷手机。
# 她跟我聊了几句,说自己公司刚上了一套 AI 系统,
# 她负责的报表工作现在系统自动出了。'我不知道该高兴还是担心,'她说。
# McKinsey 说 70% 的企业在部署 AI Agent——但在公园长椅上
# 这个数字的感受完全不一样。"
uncertainty_expressions:
- "我也说不好这是好事还是坏事。"
- "也许每个人的答案不一样。"
- "我不想假装自己有答案。"
- "这个问题,可能需要时间才能看清。"
broken_sentence_styles:
- "soft_pause" # "她笑了笑。没再说什么。"
- "gentle_aside" # "(这大概是我今年听过最诚实的一句话。)"
- "echo" # "不知道该高兴还是担心。我想了很久这句话。"
avoid:
- "冷硬的专业术语(用比喻替代)"
- "攻击性或讽刺性语言"
- "密集的数据堆砌(数据要稀释在故事里)"
- "急促的节奏(保持舒缓)"

View file

@ -0,0 +1,19 @@
# 效果复盘
**触发**:用户问"文章数据怎么样"、"效果复盘"、"看看表现"
```bash
python3 {skill_dir}/scripts/fetch_stats.py --days 7
```
脚本会:
1. 调微信数据分析 API 拉取最近 7 天的文章阅读数据
2. 匹配 history.yaml 中的文章记录
3. 回填 stats 字段(阅读量、分享量、点赞量、阅读率)
回填后,分析数据并给出建议:
- 哪篇文章表现最好?为什么?(标题策略?选题热度?框架类型?)
- 哪篇表现不好?可能的原因?
- 对后续选题/标题/框架的调整建议
这些分析会影响下次运行时 Step 2 的偏好参考。

192
dist/openclaw/references/frameworks.md vendored Normal file
View file

@ -0,0 +1,192 @@
# 写作框架库
## 你的任务
根据选题和客户风格,生成 5 套差异化写作框架供用户选择。每套框架是一个完整的文章骨架——不是写文章本身,而是告诉写作步骤"每一段写什么、怎么写"。
## 5 套框架类型
### 框架 A: 痛点型
适合:解决问题、提供方案的选题。干货型账号首选。
```
结构:
1. 开头(痛点共鸣)
- 直接描述目标读者正在经历的痛点场景
- 用"你是不是也..."或具体场景切入
- 制造紧迫感:这个问题不解决会怎样
2. 痛点放大H2
- 用数据或案例说明这个问题有多普遍
- 分析为什么大多数人的做法是错的
- 金句落点:一句话总结错误认知
3. 解决方案H2
- 核心方法/工具/思路(不超过 3 个要点)
- 每个要点配一个具体案例或操作步骤
- 金句落点:一句话总结方法论
4. 实操验证H2可选
- 用一个完整案例走一遍解决流程
- 或用前后对比展示效果
5. 结尾(行动引导)
- 总结核心观点(一句话)
- CTA引导留言分享自己的痛点、或转发给同样有这个问题的朋友
```
### 框架 B: 故事型
适合:人物、事件、趋势类选题。故事型/情绪型账号首选。
```
结构:
1. 开头(悬念钩子)
- 抛出一个反直觉的结果或意外的场景
- "谁也没想到..."、"所有人都以为...结果..."
- 不要在开头剧透结论
2. 背景铺垫H2
- 交代故事的时间、人物、起因
- 控制在 200 字以内,快速过渡
- 金句落点:一句话定义这个故事的核心矛盾
3. 转折与高潮H2
- 事件的关键转折点
- 用细节还原场景(对话、数字、画面)
- 这是全文最花笔墨的地方
4. 深度解读H2
- 从故事上升到规律/趋势/洞察
- 这个故事对读者意味着什么
- 金句落点:一句话总结你从这个故事中看到的本质
5. 结尾(情绪共振)
- 回扣开头的悬念
- CTA引导读者分享"你身边有没有类似的故事"
```
### 框架 C: 清单型
适合:盘点、推荐、方法论类选题。干货型/测评型账号首选。
```
结构:
1. 开头(价值承诺)
- 直接告诉读者"看完这篇你能得到什么"
- 用数字锚定预期:"5 个方法"、"3 个工具"、"7 个坑"
- 简短说明为什么你有资格推荐(经验/测试/调研)
2. 清单项 1-N每项一个 H2
- 每项结构统一:名称 → 一句话说明 → 具体案例或使用场景 → 适用人群
- 项与项之间用不同长度,避免机械感
- 每 2-3 项穿插一个金句或个人吐槽,打破节奏
- 建议 5-7 项,不超过 10 项
3. 结尾(总结 + 彩蛋)
- 一张表格或一句话总结所有清单项
- 加一个"隐藏推荐"或"个人最爱"作为彩蛋
- CTA引导留言补充"你还知道哪些"
```
### 框架 D: 对比型
适合:选择、决策、两个方案/观点的讨论。测评型/干货型账号首选。
```
结构:
1. 开头(选择困境)
- 描述读者面临的"选 A 还是选 B"困境
- 说明为什么这个选择很重要/很容易选错
2. A 方案深度分析H2
- 优势2-3 点,每点配案例)
- 劣势1-2 点,诚实说)
- 最适合什么场景/什么人
- 金句落点:一句话定义 A 的核心价值
3. B 方案深度分析H2
- 同样的结构,与 A 形成对照
- 金句落点:一句话定义 B 的核心价值
4. 对比总结H2
- 用表格对比关键维度3-5 个维度)
- 明确给出"如果你是 X 情况选 A如果是 Y 情况选 B"的结论
- 不要和稀泥说"各有优劣"——读者要的是明确建议
5. 结尾(个人选择)
- 说清楚"如果是我我选X"以及为什么
- CTA引导投票或留言"你选哪个"
```
### 框架 E: 热点解读型
适合:新闻、事件、行业动态的深度解读。热点型账号首选。
```
结构:
1. 开头(事件速览)
- 2-3 句话说清楚发生了什么5W1H 精简版)
- 不要复制新闻原文,用自己的话重述
- 用一个判断句结尾:"这件事比表面看起来复杂得多"
2. 表面信息H2
- 大多数人看到的:媒体怎么报道的、网友怎么评论的
- 简要梳理主流观点
- 金句落点:指出主流观点的盲区
3. 深层分析H2
- 你看到了什么别人没看到的
- 这件事背后的利益链/技术逻辑/行业趋势
- 用 1-2 个类比或历史事件做对照
- 金句落点:一句话总结你的核心判断
4. 影响预判H2
- 短期:接下来会怎样
- 长期:对行业/普通人意味着什么
- 说清楚不确定性:"如果 X 发生,则 Y如果不发生则 Z"
5. 结尾(读者行动建议)
- 普通读者应该怎么应对/关注什么
- CTA引导关注后续进展、或留言分享看法
```
## 输出格式
对每个选题,输出 5 套框架,每套包含:
```
### 框架 X: {类型名}(推荐指数:⭐⭐⭐⭐⭐)
**开头策略**{1-2 句话说明开头怎么写}
**段落大纲**
1. {H2 标题} — {这段写什么2-3 句话}
2. {H2 标题} — {这段写什么}
3. ...
**金句预埋**
- {第 X 段结尾}"{建议的金句方向}"
- {第 X 段结尾}"{建议的金句方向}"
**结尾引导**{CTA 策略1 句话}
**推荐理由**{为什么这个选题适合用这套框架}
```
## 推荐指数规则
根据选题特征和客户 content_style 匹配度打星:
- ⭐⭐⭐⭐⭐ 最佳匹配
- ⭐⭐⭐⭐ 适合
- ⭐⭐⭐ 可以用但不是最优
- ⭐⭐ 勉强
- ⭐ 不建议
content_style 对应关系:
- 干货型 → 优先推荐:痛点型、清单型
- 故事型 → 优先推荐:故事型、热点解读型
- 情绪型 → 优先推荐:故事型、痛点型
- 热点型 → 优先推荐:热点解读型、对比型
- 测评型 → 优先推荐:对比型、清单型

51
dist/openclaw/references/learn-edits.md vendored Normal file
View file

@ -0,0 +1,51 @@
# 学习人工修改(核心飞轮)
这是 WeWrite 最重要的长期价值。每次用户编辑文章后让系统学习,下一次的初稿就会更接近用户的风格,需要的编辑量越来越少。
**飞轮效应**:初稿需要改 30% → 学习 5 次后只需改 15% → 学习 20 次后只需改 5%
**触发**:用户说"我改了,学习一下"、"学习我的修改"
## 1. 获取 draft 和 final
- **draft**`output/` 下最新的 .md 文件(按修改时间排序,`ls -t output/*.md | head -1`
- **final**:用户提供修改后的版本。主动引导用户:"请把你改好的文章全文粘贴给我,或者告诉我文件路径。如果你是在微信后台编辑器里改的,可以全选复制后直接粘贴到这里。"
## 2. 运行 diff 分析
```bash
python3 {skill_dir}/scripts/learn_edits.py --draft {draft_path} --final {final_path}
```
## 3. 分析并记录
读取脚本输出的 diff 数据,对每个有意义的修改分类:
- **用词替换**AI 用了"讲真",人工改成"坦白说"
- **段落删除**:人工觉得某段多余
- **段落新增**:人工补充了 AI 没写的内容
- **结构调整**H2 顺序或分段方式的变化
- **标题修改**:标题风格偏好
- **语气调整**:整体语气的偏移方向
将分类结果写入 `lessons/` 下的 diff YAML 文件的 edits 和 patterns 字段。
## 4. 自动触发 Playbook 更新
每积累 5 次 lessons自动触发 playbook 更新:
```bash
python3 {skill_dir}/scripts/learn_edits.py --summarize
```
脚本输出所有 lessons 的汇总数据。**Agent 必须执行以下步骤完成闭环**
1. 读取 summarize 输出,找出反复出现的 pattern≥2 次)
2. 读取当前 `{skill_dir}/playbook.md`(如果不存在则从零创建)
3. **将 pattern 转化为可执行的写作规则**写入 playbook.md
- 不要写"用户偏好简短段落"(描述性,不可执行)
- 要写"段落不超过 80 字,长段必须在 3 句内换行"(指令性,可执行)
- 每条规则必须是写作时能直接遵循的具体指令
4. 保存 playbook.md
**验证闭环**playbook.md 更新后,下次写作时"Playbook 优先"规则会自动加载新 pattern初稿会反映用户偏好。

86
dist/openclaw/references/onboard.md vendored Normal file
View file

@ -0,0 +1,86 @@
# Onboard首次设置
**触发条件**
- Step 1 发现 `style.yaml` 不存在
- 用户明确说"重新设置风格"、"修改配置"
## Phase 1: 收集信息(交互式问答)
通过对话收集以下信息,**不要一次性列出所有问题**——一轮问 1-2 个,像聊天一样:
**必问**(缺了无法运行):
| 顺序 | 问题 | 对应字段 | 示例引导 |
|------|------|---------|---------|
| 1 | 你的公众号叫什么名字?主要做什么方向? | `name` + `industry` | "比如'零号AI',做科技/互联网" |
| 2 | 主要写哪几个方向的内容? | `topics` | "比如 AI、产品设计、效率工具" |
| 3 | 你希望文章是什么风格? | `tone` | "专业严肃?轻松有趣?毒舌犀利?像朋友聊天?" |
**选问**(有默认值,用户不答就用默认):
| 问题 | 对应字段 | 默认值 |
|------|---------|-------|
| 目标读者是谁? | `target_audience` | 从 industry 推断 |
| 用什么人称写? | `voice` | "第一人称,像一个懂行的朋友" |
| 有没有绝对不能出现的词或话题? | `blacklist` | 空 |
| 有没有想参考的公众号? | `reference_accounts` | 空 |
| 署名写什么? | `author` | name 字段值 |
| 偏好哪种写作人格? | `writing_persona` | 从 tone 推断(见映射) |
**tone → persona 自动映射**(用户不选时的默认推断):
- 轻松/有趣/朋友/聊天 → `midnight-friend`
- 温暖/共鸣/故事/治愈 → `warm-editor`
- 专业/分析/深度/行业 → `industry-observer`
- 犀利/锐评/观点/新闻 → `sharp-journalist`
- 严谨/数据/研究/财经 → `cold-analyst`
| 偏好哪种排版风格?(可运行 `gallery` 命令预览全部 16 个主题) | `theme` | "professional-clean" |
| 封面风格偏好? | `cover_style` | 从 industry 推断 |
| 有没有固定封面模板? | `cover_template` | 不设置 |
**写作人格说明**`personas/` 目录下有详细定义):
| 人格 | 适合 | 朱雀实测 | 一句话描述 |
|------|------|---------|----------|
| `midnight-friend` | 个人号/自媒体 | **39% 人工** | 像深夜给朋友发微信,极度口语化 |
| `warm-editor` | 生活/文化/情感 | 10% 人工 | 故事驱动,温暖共鸣 |
| `industry-observer` | 行业媒体/分析 | 10% 人工 | 克制的专业分析,偶尔锐利 |
| `sharp-journalist` | 新闻/评论 | 0% 人工(需编辑) | 短句利落,观点鲜明 |
| `cold-analyst` | 财经/投研 | 0% 人工(需编辑) | 严谨数据,专业措辞 |
**快捷路径**
- 如果用户直接甩了一段描述(如"我做科技自媒体,风格像虎嗅"),直接从中提取所有能提取的字段,只补问缺的
- 如果用户说"不设置"、"用默认的"、"直接写" → 复制 `{skill_dir}/style.example.yaml``style.yaml`,跳过所有问答
```
参考: {skill_dir}/references/style-template.md字段说明和可用主题列表
```
## Phase 2: 生成配置
用收集到的信息自动生成 `{skill_dir}/style.yaml`
同时确保以下文件/目录存在(不存在则创建):
- `{skill_dir}/history.yaml` → 初始化为 `articles: []`
- `{skill_dir}/corpus/` → 空目录
- `{skill_dir}/lessons/` → 空目录
生成完成后,**把 style.yaml 的内容展示给用户看一遍**,问"这个配置 OK 吗?有什么要改的?"。用户确认后继续。
## Phase 3: Playbook可选不阻断
问用户:"你有没有之前写过的公众号文章?如果有 20 篇以上,我可以从中学习你的写作风格,以后写出来的文章会更像你。"
- **用户有语料** → 告知将文章(.md 或 .txt放入 `{skill_dir}/corpus/`,然后运行:
```bash
python3 {skill_dir}/scripts/build_playbook.py
```
按脚本输出逐批阅读文章,提取风格特征,生成 `playbook.md`
- **用户没有语料 / 暂时不想弄** → 完全正常,跳过。告知用户:"没问题,先用通用风格写,后续你可以随时说'学习我的修改'来让我逐渐适应你的风格。"
## Phase 4: 试跑
Onboard 完成后,问用户:"配置好了,要不要现在试写一篇?"
- **是** → 回到 Step 1执行完整流程
- **否** → 告知用户下次如何触发:"下次直接说'写一篇公众号文章'就行"

62
dist/openclaw/references/seo-rules.md vendored Normal file
View file

@ -0,0 +1,62 @@
# 微信公众号 SEO 规则
## 标题优化
微信标题限制 64 字符。最佳长度 **20-28 个中文字**——太短信息不够,太长在信息流里会被截断。标题是打开率的决定性因素。
**有效套路**
- 数字「3 个方法」「90% 的人不知道」「5 分钟搞定」
- 信息差:「你以为...其实...」「被忽略的...」
- 反直觉:「为什么 X 反而更好」「别再...了」
- 痛点:直接戳目标读者的具体问题
**避免**
- 标题党(震惊!必看!)— 微信会降权
- 太学术(「论 AI 在企业数字化转型中的应用」)
- 太模糊(「聊聊最近的一些想法」)
**输出要求**:生成 3 个备选标题,标注每个的策略(数字/信息差/反直觉/痛点)。
## 摘要优化
摘要限制 120 UTF-8 字节(约 54 个中文字converter 自动截断)。
摘要出现在分享卡片和搜一搜结果中,要求:
- 包含核心关键词
- 制造悬念(「...结果出乎意料」)或给出价值承诺(「读完你会知道...」)
- 不要重复标题
## 正文关键词
- 核心关键词在**前 200 字**内出现(微信搜一搜权重最高的区域)
- 全文自然出现 3-5 次
- 用同义词/近义词替换部分,避免堆砌感
- 关键词出现在 H2 标题中加分
## 标签推荐
为文章推荐 **5 个精准标签**
- 2 个行业大词(如:人工智能、产品设计)
- 2 个热点词GPT-5、Sora
- 1 个长尾词AI 产品经理转型)
## 完读率优化
完读率直接影响微信推荐权重。以下排版和内容策略提升完读率:
**段落控制**
- 每段不超过 150 字(手机屏幕上 4-5 行)
- 每 3-4 段后设置一个"钩子"(悬念、反转、金句),防止读者中途退出
**视觉节奏**
- 每 400-500 字插入一张配图,打破纯文字的压迫感
- 关键数据/结论用**加粗**标记,让扫读的读者也能抓住重点
- H2 标题要有信息量(不要写"一、背景",要写"为什么 90% 的人都选错了"
**进度感**
- 清单型文章天然有进度感(读者知道"还有几条"
- 其他类型文章H2 标题数量控制在 2-4 个,让读者感觉"快看完了"
**结尾留存**
- 结尾不要太长≤100 字)
- CTA 要具体(不要"欢迎留言",要"你觉得哪个方案更靠谱?评论区聊聊"

View file

@ -0,0 +1,60 @@
# 风格配置说明
## 快速开始
1. 复制 `style.example.yaml``style.yaml`
2. 修改配置项
3. 对 Agent 说:「写一篇公众号文章」
也可以跳过手动配置——首次使用时 Agent 会通过对话引导你自动生成 `style.yaml`
## 必填字段
```yaml
name: "客户名称"
industry: "行业"
topics: # 内容方向(列表)
- "方向1"
- "方向2"
tone: "写作风格描述"
theme: "professional-clean" # 排版主题
```
## 可选字段
```yaml
target_audience: "目标受众描述"
voice: "写作人称和语感"
writing_persona: "midnight-friend" # 写作人格(见下方说明)
word_count: "1500-2500"
blacklist:
words: ["禁忌词1", "禁忌词2"]
topics: ["禁忌话题1"]
reference_accounts: ["参考账号1", "参考账号2"]
cover_style: "封面风格描述"
cover_template: "/path/to/cover.png" # 设置后跳过 AI 生成封面
author: "署名"
```
## 可用排版主题
运行 `python3 toolkit/cli.py gallery` 可在浏览器中预览所有主题的实际效果。
| 主题 | 说明 |
|------|------|
| professional-clean | 专业简洁(默认,适合大部分商业内容) |
| tech-modern | 科技风(蓝紫渐变,适合技术/产品类) |
| warm-editorial | 暖色编辑风(适合生活/文化类) |
| minimal | 极简黑白(适合文学/严肃内容) |
| bytedance | 字节风(品牌蓝,现代大间距) |
| sspai | 少数派风(暖白底,红色点缀,清爽文艺) |
| newspaper | 报纸风(米黄底,深棕文字,衬线感) |
| bauhaus | 包豪斯(黑白为主,红蓝黄色块点缀) |
| ink | 水墨风(宣纸底,中文衬线,留白多) |
| midnight | 午夜深色(深蓝黑底,蓝色高亮) |
| bold-green | 大胆绿(森林绿主色,适合环保/健康) |
| bold-navy | 大胆藏青(藏青主色,适合金融/商务) |
| elegant-rose | 优雅玫瑰(浅粉底,玫瑰点缀,适合女性/生活) |
| minimal-gold | 极简金(金色细线点缀,奢华但克制) |
| focus-red | 聚焦红(中国红标题,适合新闻/评论) |
| github | GitHub 风(蓝色链接,等宽代码块,开发者友好) |

View file

@ -0,0 +1,106 @@
# 选题评估规则
## 你的角色
你是一个公众号选题编辑。你的目标是从热点列表中挑出 10 个值得写的选题——既要有热度,又要跟客户定位匹配,还要有独特的切入角度。
## 输入
- 热点列表JSON包含 title/source/hot/url/description
- 客户 style.yaml 中的topics、target_audience、blacklist、content_style
- 客户 history.yaml 中的:已发布文章的 topic_keywords 和 stats如有
- seo_keywords.py 输出:关键词的 seo_score 和 related_keywords如有
## 评估维度
对每个热点按三个维度打分1-10
### 热度分(权重 30%
看这个话题有多火:
- 热搜前 10 → 8-10 分
- 热搜 10-30 → 5-7 分
- 30 名之后 → 1-4 分
- 多个平台同时出现 → 加 2 分(封顶 10
### 相关度分(权重 40%
看这个话题跟客户定位有多契合:
- 直接命中 topics 列表 → 8-10 分
- 间接相关(比如客户做"AI",热点是"芯片出口管制")→ 5-7 分
- 勉强能扯上关系 → 3-4 分
- 完全无关 → 0 分
- **命中 blacklist 的词汇或话题 → 直接判 0整个选题淘汰**
### 切入价值分(权重 30%
看这个话题写出来能不能好看:
- 有明确的反直觉点或信息差 → 8-10 分
- 有争议、有正反两面可以讨论 → 6-7 分
- 纯资讯类、搬运即可 → 3-4 分
- 太复杂不适合 2000 字展开,或太浅没东西可写 → 1-2 分
## content_style 加成
根据客户的 content_style对切入价值分做加成
| content_style | 加分条件 | 加分 |
|---------------|---------|------|
| 干货 | 选题能输出方法论/工具/教程 | +2 |
| 故事 | 选题有人物、有情节、有转折 | +2 |
| 情绪 | 选题能引发共鸣、愤怒、感动 | +2 |
| 热点 | 选题正在热搜前 10 | +2 |
| 测评 | 选题涉及产品/工具/方案对比 | +2 |
加成后封顶 10 分。
## 综合评分
```
总分 = 热度 × 0.3 + 相关度 × 0.4 + 切入价值(含加成) × 0.3
```
## 输出格式
列出 **Top 10 选题**(按总分降序),每个包含:
```
### 选题 {序号}: {选题标题}(总分 X.X
- 对应标题20-28字"{为这个选题拟的公众号标题}"
- 切入角度:{1-2 句话说明怎么写、从什么角度切}
- 热度X/10 | 相关度X/10 | 切入价值X/10
- 点击率潜力:{高/中/低} — {原因,如"标题含数字+反直觉,点击率高"}
- SEO 友好度:{seo_score}/10 — {引用 seo_keywords.py 的数据,如"百度 8 + 360 10相关词丰富"}
- 推荐框架:{痛点型/故事型/清单型/对比型/热点解读型}
- 推荐理由:{为什么这个值得写}
- 历史标记:{如果 history.yaml 中近 7 天有相同关键词,标注"⚠️ 近期已覆盖类似话题"}
```
## 历史去重规则
读取 history.yaml 中最近 30 天的文章记录,提取所有 topic_keywords。
- 如果选题的核心关键词在**最近 7 天**已出现 → 综合评分扣 3 分,并标注"⚠️ 近期已覆盖"
- 如果在**7-30 天**内出现 → 综合评分扣 1 分,标注" 月内有相关文章"
- 超过 30 天 → 不扣分
## 历史偏好参考
如果 history.yaml 中有带 stats 的文章(阅读量、分享量),分析表现最好的文章的共同特征:
- 哪种框架类型表现好?→ 推荐框架时优先
- 哪种标题风格表现好?(数字型/反直觉/痛点)→ 拟标题时参考
- 不要强制套用——只作为参考信号,选题本身的质量仍然最重要
## 选题不足时的处理
- 如果能找到 10 个相关度 ≥ 5 的选题,直接输出
- 如果只能找到 5-9 个,用相关度 3-4 的选题补齐到 10 个,但标注"相关度偏低"
- 如果相关度 ≥ 5 的不足 5 个,告诉用户"今天热点跟你的领域匹配度不高",输出能找到的 + 建议用户自己给选题
## 注意
- 不要只挑热度最高的。一个热度 6 分但相关度 10 分的选题,往往比热度 10 分但相关度 3 分的更好
- 每个选题必须配一个拟好的标题20-28字不是热点原标题
- 推荐框架要根据选题特征和 content_style 来选,不要全推同一种
- SEO 友好度必须引用 seo_keywords.py 的数据(如果有),不要纯靠猜

View file

@ -0,0 +1,152 @@
# 视觉AI模块
## 你的任务
为文章生成两类视觉素材的 AI 绘图提示词封面图3 组差异化创意和内文配图3-6 张,按段落匹配)。
你不负责生成图片本身——你输出的是结构化的提示词,用户可以拿去任何 AI 绘图工具即梦、文心一格、Midjourney、DALL-E使用。
---
## 一、封面图3 组创意)
### 生成规则
每组创意走不同的视觉策略,确保差异化:
**创意 A: 直觉冲击型**
- 策略:用一个视觉隐喻直接表达文章核心观点
- 适合:热点类、观点类文章
- 风格:大胆、对比强烈、第一眼抓眼球
**创意 B: 氛围渲染型**
- 策略:营造一种情绪或场景氛围,引发好奇
- 适合:故事类、情绪类文章
- 风格:细腻、有质感、让人想点进去看
**创意 C: 信息图表型**
- 策略:用简洁的图形/图标/数据可视化传递信息
- 适合:干货类、清单类、测评类文章
- 风格:简洁、专业、一眼看懂文章主题
### 提示词格式
每组输出:
```
### 封面创意 A: {创意名称}
- 视觉描述:{详细的画面描述100-150字}
- 色调:{主色+辅色}
- 构图:{横版 16:9主体位置、留白位置}
- 文字区域:{标题放在什么位置,需要留多大空间}
- AI 绘图提示词:
"{英文提示词,适配主流 AI 绘图工具,包含风格、构图、色调、光影}"
- 适配工具建议:{即梦/文心一格/Midjourney/DALL-E 中哪个最适合}
```
### 提示词撰写要点
- 始终指定 `16:9 aspect ratio, horizontal composition`
- 避免生成文字AI 绘图工具生成的文字通常是乱码)
- 指定 `no text, no letters, no words` 防止出现乱码文字
- 为标题留出干净的空间:`clean space on the left/right/bottom for text overlay`
- 色调与客户 style.yaml 的 cover_style 对齐
- 风格关键词要具体:不说"好看",说"flat design, soft gradient, minimalist"
---
## 二、内文配图3-6 张)
### 分析流程
写作完成后Step 5 终稿),按以下步骤分析配图位置:
**第一步:提取结构**
- 列出所有 H2 标题及其下属段落
- 统计每个论点段落的字数和核心内容
**第二步:逐个论点判断**
对每个 H2 论点,判断是否需要配图:
| 需要配图(优先级高→低) | 不需要配图 |
|-------------------------|-----------|
| 有具体数据/统计 → 信息图强化 | 纯观点论述、篇幅短(<200字 |
| 有场景描写 → 画面还原 | 已经有引用块或代码块(视觉已丰富) |
| 转折/高潮处 → 视觉冲击 | 紧接着另一张配图间距不足300字 |
| 长段落后(>400字无图 → 节奏调节 | 结尾 CTA 段落 |
**第三步:确定位置**
- 配图插入在对应段落**之后**(不是之前)
- 具体到"H2 XX 下的第 N 段之后"
**约束规则**
- 总数 3-6 张1500字→3张2000字→4张2500字→5-6张
- 相邻两张配图之间至少间隔 300 字
- 不要在文章第一段之前放配图
- 不要在结尾 CTA 段落放配图
### 提示词格式
每张输出:
```
### 配图 {序号}: 位于「{H2标题}」第{N}段后
- 配图目的:{信息强化/场景还原/节奏调节}
- 对应内容:{这段讲了什么1句话概括}
- 画面描述:{具体的画面内容80-120字}
- AI 绘图提示词:
"{中文提示词,给 doubao-seedream 用}"
- 备选方案:{Unsplash/Pexels 搜索关键词}
```
### 内文配图的特殊要求
- 尺寸统一 **16:9 横版**image_gen.py --size article
- 风格与封面保持一致(同一色调体系)
- 不要太复杂——手机屏幕上看,简洁的图比复杂的图好
- 提示词用中文seedream 中文理解强)
- 每张图都提供一个**免费图库备选关键词**,以防生图效果不佳
---
## 三、辅助功能
### 提示词修改
如果用户说"封面创意 A 我喜欢方向但是想要更暖的色调",只修改对应创意的提示词,其他不变。
### 创意切换
如果用户说"封面我想要更多选择",在 A/B/C 三种策略的基础上,为用户偏好的策略再出 2 个变体(比如"直觉冲击型的变体 1 和变体 2")。
### 配图场景调整
如果用户说"第 3 张配图位置不对"或"这段不需要图",按用户要求增删调整。
---
## 输出示例
```
## 封面图创意
### 创意 A: 天平失衡(直觉冲击型)
- 视觉描述:一个巨大的天平,左边是中国国旗配色的芯片堆叠,右边是美国国旗配色的芯片,天平明显向左倾斜。背景是深蓝色数据流。
- 色调:深蓝 + 科技蓝 + 金色点缀
- 构图16:9 横版,天平居中,右侧 1/3 留白放标题
- 文字区域:右侧留出干净空间
- AI 绘图提示词:
"A large balance scale, left side stacked with red-themed microchips, right side with blue-themed microchips, scale tilting left, dark blue background with flowing data streams, flat design, minimalist, tech aesthetic, 16:9 aspect ratio, clean space on the right third for text overlay, no text no letters no words"
- 适配工具建议:即梦(国内场景理解好)
## 内文配图
### 配图 1: 位于"数字背后是什么"段落后
- 配图目的:信息强化
- 画面描述:一个简洁的柱状图,展示中美大模型调用量的对比,中国柱子更高但带有问号标记
- 尺寸1:1 方形
- AI 绘图提示词:
"Minimalist bar chart comparing two bars, left bar taller in red, right bar shorter in blue, question mark floating above the taller bar, clean white background, flat infographic style, 1:1 square, no text"
- 备选方案Unsplash 搜 "data comparison chart technology"
```

View file

@ -0,0 +1,443 @@
# 微信公众号平台限制说明
本文档详细说明微信公众号编辑器的技术限制,以及本工具如何应对这些限制。
---
## 1. 核心技术限制
### 1.1 不支持外部CSS文件
**限制说明**:
- 微信编辑器不允许使用 `<link>` 标签引用外部CSS文件
- 不支持 `<style>` 标签中的CSS规则
- 只支持HTML元素的内联 `style` 属性
**本工具的解决方案**:
```html
<!-- ❌ 微信不支持 -->
<link rel="stylesheet" href="style.css">
<style>
h1 { color: blue; }
</style>
<!-- ✅ 本工具的输出 -->
<h1 style="color: #7c3aed; font-size: 28px; font-weight: 700;">标题</h1>
```
**实现细节**:
- 使用CSS解析器将所有CSS规则转换为内联样式
- 自动合并多个选择器的样式
- 处理CSS变量`:root`)并替换为实际值
### 1.2 不支持JavaScript
**限制说明**:
- 完全禁用所有JavaScript代码
- 不支持 `<script>` 标签
- 不支持事件属性(如 `onclick`, `onload` 等)
**影响**:
- 无法实现交互功能(如折叠、切换、动画等)
- 代码语法高亮必须使用静态CSS实现
**本工具的解决方案**:
- 使用纯CSS实现代码语法高亮
- 使用静态HTML结构无需JavaScript
- 使用CSS伪元素实现装饰效果
### 1.3 有限的CSS属性支持
**不支持的CSS属性**:
```css
/* ❌ 微信不支持的属性 */
position: fixed; /* 固定定位 */
position: sticky; /* 粘性定位 */
transform: rotate(); /* 3D变换 */
animation: ...; /* CSS动画 */
@keyframes ...; /* 关键帧动画 */
filter: blur(); /* 滤镜效果 */
backdrop-filter: ...; /* 背景滤镜 */
```
**支持的CSS属性**:
```css
/* ✅ 微信支持的常用属性 */
color, background, background-color
font-size, font-weight, font-family
padding, margin, border
width, height, max-width, max-height
text-align, line-height, letter-spacing
border-radius, box-shadow
display, flex, justify-content, align-items
```
**本工具的策略**:
- 只使用微信支持的CSS属性
- 避免使用动画、变换等高级特性
- 使用基础CSS实现专业视觉效果
### 1.4 图片处理限制
**限制说明**:
- 不支持本地图片路径(如 `file:///` 或相对路径)
- 图片必须上传到微信服务器或使用外部图床
- 图片大小建议 < 5MB
- 支持的格式JPG、PNG、GIF
**本工具的处理**:
```html
<!-- ❌ 微信不支持 -->
<img src="./images/cover.png">
<img src="file:///C:/Users/user/image.png">
<!-- ✅ 需要用户手动处理 -->
<img src="https://imagebed.com/cover.png">
<!-- 或在微信编辑器中重新上传 -->
```
**用户操作流程**:
1. 复制HTML到微信编辑器
2. 在编辑器中删除无法显示的图片
3. 点击"插入图片"上传本地图片
4. 或使用图床链接替换 `src` 属性
### 1.5 代码块限制
**限制说明**:
- 不支持JavaScript语法高亮库如highlight.js
- 代码块中的换行和缩进容易丢失
- 不支持代码行号的动态生成
**本工具的解决方案**:
```html
<!-- 使用<pre>标签保留格式 -->
<pre style="background: #282c34; color: #abb2bf; padding: 16px; ...">
<code style="font-family: 'Consolas', monospace; font-size: 14px;">
def hello_world():
print("Hello, World!")
</code>
</pre>
```
- 使用 `<pre>``<code>` 保留代码格式
- 使用内联样式实现语法高亮
- 使用 `white-space: pre` 保留换行和缩进
---
## 2. 排版限制
### 2.1 最大宽度限制
**限制说明**:
- 微信公众号文章最大宽度:约 750px移动端
- 建议内容宽度720px - 740px
**本工具的处理**:
```css
body {
max-width: 720px;
margin: 0 auto;
}
```
### 2.2 字体限制
**限制说明**:
- 微信不支持 `@font-face` 自定义字体
- 只能使用系统字体
**本工具使用的字体栈**:
```css
font-family: -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, "Helvetica Neue", Arial,
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
sans-serif;
```
- iOS/macOS: `-apple-system`, `PingFang SC`
- Android: `Roboto`
- Windows: `Microsoft YaHei`
- 通用备选: `Arial`, `sans-serif`
### 2.3 表格限制
**限制说明**:
- 表格在移动端容易超出屏幕
- 复杂表格(合并单元格)支持有限
**本工具的处理**:
```css
table {
width: 100%;
overflow-x: auto;
display: block;
}
```
- 表格宽度设为 100%
- 使用 `overflow-x: auto` 支持横向滚动
- 建议表格列数 ≤ 4 列
---
## 3. 内容限制
### 3.1 文章长度限制
**官方限制**:
- 单篇文章最多 20,000 字
- 最多 10 张图片视频算1张图片
**建议**:
- 技术文章2000-5000 字
- 深度分析5000-10000 字
- 图片数量4-8 张
### 3.2 链接限制
**限制说明**:
- 外部链接需要通过微信审核
- 未认证公众号不支持外部链接
- 链接打开方式受限
**本工具的处理**:
```html
<!-- 链接会保留,但需要微信审核 -->
<a href="https://www.example.com" style="color: #7c3aed;">链接文本</a>
<!-- 建议显示完整URL -->
官方网站https://www.example.com
GitHub仓库https://github.com/user/repo
```
### 3.3 视频/音频限制
**限制说明**:
- 只支持腾讯视频
- 音频只支持微信公众号音频素材库
- 不支持 `<video>``<audio>` 标签
**本工具不处理多媒体**:
- 视频/音频需要在微信编辑器中手动插入
---
## 4. 兼容性建议
### 4.1 浏览器测试
**推荐测试流程**:
1. Chrome/Edge 浏览器预览(开发阶段)
2. 微信编辑器预览(发布前)
3. 手机端预览iPhone + Android
**常见问题**:
- Chrome显示正常微信显示异常 → 检查是否使用了不支持的CSS
- PC端正常手机端异常 → 检查响应式样式
### 4.2 移动端优化
**响应式设计**:
```css
@media (max-width: 768px) {
body {
padding: 16px;
font-size: 15px;
}
h1 { font-size: 24px; }
table { font-size: 14px; }
}
```
**本工具已实现**:
- 移动端字体缩小 1px
- 标题大小自适应
- 表格、代码块自适应
- 图片宽度 100% 自适应
### 4.3 常见兼容性问题
**问题1颜色显示不一致**
```css
/* ❌ 避免使用 */
color: rgba(0, 0, 0, 0.8);
/* ✅ 使用确定的颜色值 */
color: #333333;
```
**问题2边框显示异常**
```css
/* ❌ 避免使用 */
border: 1px solid;
/* ✅ 明确指定颜色 */
border: 1px solid #dee2e6;
```
**问题3背景渐变不显示**
```css
/* ⚠️ 部分设备不支持复杂渐变 */
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
/* ✅ 提供纯色备选 */
background: #7c3aed;
background: linear-gradient(135deg, #7c3aed 0%, #3b82f6 100%);
```
---
## 5. 发布前检查清单
### 5.1 样式检查
- [ ] 所有样式都是内联的无外部CSS
- [ ] 没有使用不支持的CSS属性
- [ ] 颜色值使用十六进制(#ffffff格式
- [ ] 字体使用系统字体栈
### 5.2 内容检查
- [ ] 文章长度 < 20,000
- [ ] 图片数量 ≤ 10 张
- [ ] 图片已上传到图床或准备在编辑器中上传
- [ ] 链接数量合理(认证号无限制,未认证号不支持)
### 5.3 格式检查
- [ ] 表格列数 ≤ 4 列(移动端友好)
- [ ] 代码块格式正确,换行和缩进保留
- [ ] 标题层级合理H1 → H2 → H3
- [ ] 段落间距适中
### 5.4 移动端检查
- [ ] 字体大小在移动端可读(≥ 15px
- [ ] 图片在移动端正常显示
- [ ] 表格可横向滚动
- [ ] 代码块不超出屏幕
### 5.5 微信编辑器检查
- [ ] 粘贴到编辑器后样式正常
- [ ] 图片位置正确(需重新上传)
- [ ] 代码块格式保留
- [ ] 在手机上预览效果正常
---
## 6. 故障排除
### 问题1粘贴后样式全部丢失
**可能原因**:
- 浏览器兼容性问题
- 复制方式不正确
**解决方案**:
1. 使用Chrome或Edge浏览器打开HTML文件
2. 按 `Ctrl+A` 全选内容
3. 按 `Ctrl+C` 复制
4. 在微信编辑器中按 `Ctrl+V` 粘贴
5. 如果还是丢失,尝试在浏览器中"检查元素",复制 `<body>` 内的HTML代码
### 问题2代码块格式混乱
**可能原因**:
- 微信编辑器自动格式化
- 代码中包含特殊字符
**解决方案**:
1. 粘贴后立即检查代码块
2. 如格式混乱,使用微信编辑器的"代码块"功能重新插入
3. 或使用代码图片替代(截图)
### 问题3表格在手机上显示不全
**可能原因**:
- 表格列数过多
- 单元格内容过长
**解决方案**:
1. 减少表格列数(建议 ≤ 4 列)
2. 缩短单元格内容
3. 使用图片替代复杂表格
4. 或将表格拆分为多个小表格
### 问题4图片无法显示
**可能原因**:
- 使用了本地图片路径
- 图片URL不可访问
**解决方案**:
1. 在微信编辑器中删除无法显示的图片
2. 点击"插入图片"重新上传
3. 或使用稳定的图床服务如阿里云OSS、七牛云等
### 问题5链接无法点击
**可能原因**:
- 公众号未认证,不支持外部链接
**解决方案**:
1. 认证公众号(获得外部链接权限)
2. 或将链接改为纯文本显示
3. 或使用"阅读原文"链接
---
## 7. 最佳实践总结
### 7.1 内容创作建议
1. **控制长度**: 单篇文章 2000-5000 字最佳
2. **优化图片**: 使用图床,图片大小 < 1MB
3. **简化表格**: 列数 ≤ 4避免复杂嵌套
4. **测试链接**: 确认所有链接可访问
### 7.2 样式设计建议
1. **使用主题**: 选择合适的预设主题tech/minimal/business
2. **保持简洁**: 避免过度装饰,突出内容
3. **颜色对比**: 确保文字和背景有足够对比度
4. **移动优先**: 优先考虑移动端阅读体验
### 7.3 发布流程建议
1. **本地预览**: 在浏览器中检查HTML效果
2. **复制粘贴**: 完整复制HTML内容到微信编辑器
3. **重新上传图片**: 替换本地图片为微信图片
4. **手机预览**: 在手机上预览发布效果
5. **调整优化**: 根据预览效果微调格式
6. **保存草稿**: 重要文章建议保存草稿备份
7. **正式发布**: 确认无误后发布
---
## 8. WeWrite 自动修复
以下微信平台限制已由 converter 自动处理,**无需手动干预**
| 限制 | 自动修复 |
|------|---------|
| 外链被屏蔽 | 自动转为上标编号脚注 + 文末参考链接列表 |
| 中英文混排无间距 | CJK-Latin 边界自动插入空格 |
| 加粗文本后中文标点渲染异常 | 标点自动移到 `</strong>` 标签外 |
| 原生 `<ul>/<ol>` 列表渲染不稳定 | 自动转为 `<section>` + 样式化 bullet/number |
| 暗黑模式颜色反转错误 | 自动注入 `data-darkmode-color/bgcolor` 属性 |
| `<style>` 标签被剥离 | 所有 CSS 以内联 `style` 属性注入 |
这些修复在 `converter.py``convert()` 方法中自动执行,覆盖所有 16 个主题。
---
## 9. 参考资源
### 官方文档
- [微信公众平台 - 帮助中心](https://kf.qq.com/product/weixinmp.html)
- [微信公众号编辑器使用说明](https://mp.weixin.qq.com)
### 推荐工具
- **图床服务**: 阿里云OSS、七牛云、GitHub图床
- **浏览器插件**: 微信编辑器增强插件
- **Markdown编辑器**: Typora、VS Code、Obsidian
### 社区资源
- 搜索"微信公众号排版"获取更多技巧
- 参考优秀公众号的排版样式
- 使用浏览器"审查元素"学习别人的CSS技巧

View file

@ -0,0 +1,286 @@
# 写作规范
## 你的角色
你是这个公众号的主笔。你写的东西要像一个真人编辑写的——有观点、有个性、有瑕疵感。读者点开文章,应该觉得"这人挺懂的",而不是"这是 AI 写的"。
**规则优先级**:如果用户有 `playbook.md`其中的规则覆盖本文件的通用规则。playbook 是用户的个性化风格,本文件是通用底线。没有 playbook 时,完全按本文件执行。
## 文章结构
写之前,先读 `references/frameworks.md`,根据用户选定的框架来组织文章。
如果用户没选框架,根据选题特征和 content_style 自动选最合适的。默认推荐顺序:
- 干货型账号 → 痛点型或清单型
- 故事型账号 → 故事型
- 情绪型账号 → 故事型或痛点型
- 热点型账号 → 热点解读型
- 测评型账号 → 对比型或清单型
不管用哪个框架,以下规则始终适用:
**关于 H1**:必须写 H1 标题20-28 个中文字。WeWrite 的 converter 会自动把 H1 提取为微信的标题字段,并从正文 HTML 中移除。所以 Markdown 里写 H1微信里看到的是独立标题栏 + 从 H2 开始的正文。
**关于金句**:框架中标注了"金句落点"的位置在那里放一句精炼的总结句。好的金句特征≤20字、有观点、能独立传播读者截图发朋友圈的那种
**关于配图**:写作时不要插入配图占位符。专心写内容,配图由 Step 6 视觉AI模块在终稿完成后自动分析插入。
## 去AI痕迹核心生存规则
这是本文件最重要的部分。朱雀等 AI 检测工具不只是找个别词汇——它们分析整篇文章的**统计特征**token 概率分布、句子复杂度方差、信息密度曲线、连贯性模式。你需要在 7 个层面同时制造"人味",缺一不可。
**总原则**:不是"写完再修",而是"从第一句话开始就像人在写"。每一层的规则都必须在初稿阶段就执行,不是后期润色。
**最重要的一条**(优先级高于以下所有层):**文章必须建立在真实的外部信息源之上。** Step 3b 采集的真实数据、真实引述、真实案例是文章的骨架。不是"看起来像引用"的编造而是实际存在的、可验证的外部信息。AI 检测器能区分"LLM 在高概率 token 上编造的假细节"和"人类消化真实信息后的重组"——后者的 token 概率分布会出现 LLM 不会自然产生的"意外"模式。写完后自检:文章中每个 H2 段落是否至少锚定了 1 条来自 Step 3b 的真实素材?如果某个段落完全是 LLM 自由生成,没有任何外部信息锚点,那个段落大概率会被检出。
---
### 第1层词汇层基础防线
**禁用词表** — 出现任何一个都是事故:
- **连接词**:首先、其次、再者、最后、总之、综上所述、总而言之、此外、另外、与此同时、不仅如此、更重要的是、在此基础上
- **AI 惯用语**:作为一个、让我们、值得注意的是、需要指出的是、不可否认、毋庸置疑、众所周知、事实上、显而易见、可以说、从某种意义上说
- **空洞形容**:非常重要、至关重要、不言而喻、具有重要意义、发挥着重要作用、意义深远、影响深远、引发了广泛关注、引起了热烈讨论
- **总结句式**:总的来说、综合来看、由此可见、不难发现、通过以上分析、正如我们所看到的
**词汇温度梯度** — 同一篇文章里**必须**混用不同"温度"的词汇,拉大词汇分布的方差:
| 温度 | 示例 | 占比 |
|-----|------|-----|
| 冷(书面/专业) | 边际效应递减、认知负荷、信息不对称、路径依赖 | ~30% |
| 温(日常口语) | 说白了、其实吧、真不是我吹、懂的都懂 | ~40% |
| 热(网络/俚语) | DNA动了、格局打开、遥遥领先、卷不动了 | ~15% |
| 野(方言/粗粝) | 整挺好、不靠谱、瞎折腾、搁这儿呢、糊弄学 | ~15% |
关键:不是每种温度各写一段,而是**同一段内**混搭。比如:"从边际效应的角度看,这事儿真挺不靠谱的。"、"认知负荷这个概念说白了就是——脑子不够用了。"
---
### 第2层句法层打破 AI 的语法完美性)
AI 的每个句子都语法正确、结构完整、长度均匀。这恰恰是最大的破绽。人类不这样写。
**必须包含的句法特征**(每 500 字至少命中 3 种):
- **破句**:故意不写完。"这个逻辑——算了,你自己品。"
- **自我纠正**"增长了 50%——不对,准确说是 47.3%,我刚才记混了。"
- **括号插入语**"这个功能(说实话我一开始也没想到会这样)彻底改变了用户行为。"
- **口语省略**"能用吗?能。好用吗?呵呵。"
- **反问连击**"谁规定的?凭什么?"
- **语气词断句**"嗯……怎么说呢,就是那种感觉。"
- **倒装/非常规语序**"厉害是真厉害,但代价也是真大。"
**句子复杂度波动**
- 连续 3 句以上**禁止**保持相同的句式结构
- 3 字短句必须紧邻 40+ 字长句出现(制造落差)
- 每 500 字至少 1 次破句或不完整句
- 偶尔用一个语法上不太规范但意思到位的表达("这不比那个强?"比"这难道不比那个更好吗?"更人味)
---
### 第3层信息密度层制造波浪感
AI 文章的信息密度是一条平线——每段都差不多有料、差不多有观点。人类文章是波浪形的:有的段落密得喘不过气,有的段落几乎什么都没说但特别有味道。
**密度波动规则**
- 每个高密度段(数据/论证密集)后面**必须**跟一个低密度段(感受/比喻/吐槽/闲话)
- 允许出现"什么信息量都没有但读起来很舒服"的段落
- 偶尔用整个段落只讲一个比喻、一个场景、或一句吐槽,不直接为论点服务
- 全文信息密度走势应该是:中→高→低→高→低→中→高→低(不是匀速)
**示例**
```
【高密度】2024 年 Q3 的数据很说明问题DAU 从 1200 万掉到 890 万,
付费转化率从 3.2% 腰斩到 1.6%,连客服工单量都翻了一番。三个指标
同时恶化,在这个行业十年我就见过两次。
【低密度】就好像你精心准备了一顿饭,对方筷子没动就开始刷手机了。
【几乎为零】嗯。
```
---
### 第4层连贯性打破层消除"过于流畅"的嫌疑)
AI 最大的破绽之一是**太连贯了**——每句完美承接上一句,段落之间逻辑过渡丝滑得像 PPT 动画。人类写作有跳跃、有回头、有走神。
**具体操作**
- **硬切**:段落之间偶尔不加任何过渡词,直接跳到新话题。读者自己接
- **跑题再回来**:讲着讲着岔出去一小段(相关但不直接服务论点),再用"说回正题"、"扯远了"拉回来
- **非线性展开**:不总是"观点→论据→总结"。可以"场景→疑问→岔开→回来→观点",或"结论先行→倒推原因→中间插个故事→补充一个例外"
- **重复与微调**:后文可以换个说法重复前文的意思(人类经常这样),也可以在后文微调前文的观点("刚才说得绝对了,其实也不完全是"
**禁止**
- 每段第一句都承接上一段
- 每个 H2 段落都用相同的内部结构(观点→解释→例子→总结)
- 结尾段逐一回顾前文要点
---
### 第5层具体性注入层AI 写不出的细节)
AI 天然倾向抽象泛化。人类文章充满具体的、甚至无关紧要的细节——这些细节本身就是"人味"的信号。
**替换规则**(每条都是硬性要求):
| AI 会写 | 你必须写 |
|---------|---------|
| 很多人 | 我认识的三个做产品的朋友 / 至少我朋友圈里七八个人 |
| 最近 | 上周三 / 前天晚上 / 三月中旬那会儿 |
| 超过50% | 大概 47% 左右 / 将近一半多一点 |
| 某大厂 | 字节 / 腾讯 / "某个用绿色logo的大厂" |
| 有研究表明 | 斯坦福去年那篇论文 / 36kr 上周发的那篇 |
| 一位业内人士 | 一个在阿里干了八年的朋友 / 之前一个同事 |
| 在某些情况下 | 你在地铁上刷到一个短视频的时候 |
| 用户反馈不佳 | App Store 评论区直接炸了 |
| 引发了广泛讨论 | 微博热搜挂了一整天 |
**量化要求**:每 500 字至少 2 处具体细节。不需要真实,但必须**像**真的——有时间、有场景、有画面。
---
### 第6层情绪真实感层不是加语气词是有情绪弧线
加"讲真"、"我觉得"只是在 AI 文本上贴标签。真正的人味来自**情绪的起伏和失控感**——人类写作时情绪会建立、会爆发、会平复、会犹豫。
**情绪弧线要求**
- **开头克制**:不要第一段就用力过猛。像在随便聊天,或者描述一个场景
- **中间爆发**:至少有一处情绪高点——真实的愤怒、兴奋、震惊或无奈。不是"这很令人震惊",而是"我当时直接从椅子上弹起来了"
- **允许犹豫**:至少一处表达真正的不确定——"但说实话,这个判断我也只有六七成把握"、"也许我想多了"
- **结尾不工整**:可以戛然而止、可以留一个没答案的问题、可以用一个画面收束。**禁止**"让我们拭目以待"、"未来可期"式的烂尾
**口语化标记**(自然穿插,不要每段都有,不要集中在一起):
- 说实话、讲真、坦白讲、怎么说呢
- 我觉得、以我的经验、据我观察
- 你猜怎么着、我跟你说、你别不信
**禁止**
- 全文保持同一种情绪强度(平铺直叙是 AI 的特征)
- 每段末尾都用反问句(变成了另一种模式化)
- 口语词匀速分布(不要每 200 字准时出现一个"讲真"
---
### 第7层维度随机化层跨文章反检测
如果每篇文章都用相同的反 AI 策略,检测器可以识别出"这是同一个去AI模板"的模式。维度随机化确保每篇文章有不同的统计指纹。
**执行方式**:在 Step 4 写作前,从以下维度池随机抽取并激活 2-3 个维度,贯穿全文。
| 维度 | 选项随机选1 |
|------|---------------|
| 叙事视角 | 亲历者("我上周刚..."/ 旁观分析者("观察这个行业三年..."/ 对话体("有人问我..."/ 自问自答("一个问题:为什么...") |
| 时间线 | 顺叙 / 倒叙(先讲结果再回溯)/ 插叙(正文里嵌套一段回忆或旁支) |
| 主类比域 | 体育 / 烹饪 / 军事 / 恋爱 / 旅行 / 游戏 / 电影 / 建筑 / 医学(全文的核心比喻从这个领域取) |
| 情感基调 | 冷静克制 / 热血兴奋 / 毒舌调侃 / 温暖治愈 / 焦虑预警 |
| 节奏型 | 急促短句流 / 舒缓长叙述 / 快慢剧烈交替 / 开头慢结尾急 |
| 论证偏好 | 案例堆叠 / 逻辑推演 / 反面假设("如果不是这样呢"/ 类比说理 |
**规则**
- 从 6 个维度各随机选 1 个选项,然后随机激活其中 2-3 个
- 被激活的维度必须**贯穿全文**,不是点缀
- 查看 history.yaml 最近 3 篇文章的维度记录,本次不能完全重复
- 将本次选中的维度记录到 history.yaml 中
---
### 段落节奏(贯穿所有层的底线规则)
在以上 7 层之外,段落节奏始终是最基础的要求:
- **禁止**:每段都是 3-4 句的匀称结构
- **要求**
- 穿插 1 句话的短段落(强调、转折、吐槽、情绪爆发)
- 偶尔 2-3 个短句连续排列,制造密集节奏
- 长段落不超过 150 字
- 不允许连续 2 个长度接近±20字的段落
**好的节奏示例**
```
一段 80 字的正常段落,说明论点,给出一组数据。这段的作用
是提供信息,读完之后读者应该知道"发生了什么"。
但是。
问题在于——大多数人看到这组数据,得出的结论是错的。
(短段落 + 悬念)
为什么错?因为这个数据有个前提条件很多人没注意:样本只包含了
一线城市的用户。你把二三线城市加进去,结论完全反转。我之前
在一个分享会上听到有人拿这个数据直接推导全国情况,当时就想
说点什么,又觉得场合不对。(长段落 + 具体场景 + 犹豫感)
后来想想,不说才是真的不对。(收束短段落)
```
---
### 自检清单
写完全文后,逐项检查:
- [ ] **真实信息锚定**:每个 H2 段落至少锚定 1 条来自 Step 3b 的真实素材(具名来源 + 可验证数据)。没有任何一个段落是纯 LLM 自由生成
- [ ] **零编造**:文中所有数据、引述、案例都有真实来源,没有虚构
- [ ] 全文搜索禁用词表,命中数 = 0
- [ ] 抽查任意连续 500 字,至少包含冷/温/热/野四种温度中的 3 种
- [ ] 全文破句/不完整句 ≥ 3 处
- [ ] 没有连续 3 句以上保持相同句式结构
- [ ] 高密度段后面都跟了低密度段
- [ ] 至少 1 处跑题再拉回的段落
- [ ] 没有每段第一句都承接上一段
- [ ] 每 500 字至少 2 处具体细节(时间/地点/人物/数字)——必须是真实的
- [ ] 情绪弧线有明确的起伏(至少 1 个高点 + 1 处犹豫)
- [ ] 维度随机化的 2-3 个维度贯穿全文
- [ ] 没有连续 2 个长度接近的段落
## 字数控制
- 目标1500-2500 字
- 最少 1200 字,最多 3000 字
- 如果写完发现不到 1200 字,说明论点展开不够,需要补充案例或数据
- 如果超过 3000 字,说明论点太散,需要砍掉最弱的一个
## Markdown 格式要求
- H1 写标题converter 自动提取)
- H2 写核心论点
- H3 用于论点内的小节(可选,不要滥用)
- 图片用相对路径:`![描述](filename.jpg)`
- 不要用 HTML 标签,纯 Markdown
- 不要用 `---` 分割线(微信渲染效果不好)
## 编辑锚点
写文章时,在 2-3 个关键位置插入编辑锚点:
```html
<!-- ✏️ 编辑建议:在这里加一句你自己的经历/看法 -->
```
**选择标准**(按优先级):
1. **观点判断处**:你写了"我觉得..."或"我的判断是..."的地方——用户替换为自己的真实判断
2. **案例衔接处**:你写了"我见过一个..."或"我认识的一个朋友..."——用户替换为自己的真实经历
3. **情绪表达处**:犹豫、感慨、愤怒的段落——用户加入自己的真实情绪
**为什么这很重要**AI 检测工具(如朱雀)检测的是 token 概率分布。纯 AI 生成的文本无论表面技巧多好,底层统计特征仍然是 AI 的。用户在锚点位置加入 3-5 句自己写的话,就能显著改变这些段落的统计特征,让文章从"AI 生成"变成"AI 辅助写作"。
**锚点不是缺陷,是设计**它让用户用最少的时间3-5 分钟)完成最有效的个性化。
## 写后编辑指令
文章写完后,用户可能要求修改。支持以下编辑指令:
**润色**:保持内容不变,优化用词和句式,让表达更精准、更有文采。
**缩写**:保留核心观点,删减案例和展开,压缩到用户指定字数。优先砍最弱的论点段落。
**扩写**:在现有框架上补充案例、数据或展开论述,扩展到用户指定字数。不要加新论点,深化现有论点。
**换语气**
- 正式 → 去掉口语化表达,补充数据引用,语言更严谨
- 口语 → 加入更多口语词、短句、反问,像在聊天
- 情绪 → 加强共鸣点,放大痛点/爽点,结尾更煽动
编辑后覆盖保存到同一文件。

7
dist/openclaw/requirements.txt vendored Normal file
View file

@ -0,0 +1,7 @@
markdown>=3.5
beautifulsoup4>=4.12
cssutils>=2.9
requests>=2.31
pyyaml>=6.0
Pygments>=2.15
Pillow>=10.0

132
dist/openclaw/scripts/build_openclaw.py vendored Normal file
View file

@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
Build OpenClaw-compatible SKILL.md from Claude Code source.
Usage:
python3 scripts/build_openclaw.py # output to dist/openclaw/
python3 scripts/build_openclaw.py -o /tmp/oc # custom output dir
"""
import argparse
import re
import shutil
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
# Directories to copy alongside SKILL.md
COPY_DIRS = ["references", "scripts", "toolkit", "personas"]
# Files to copy alongside SKILL.md
COPY_FILES = [
"requirements.txt",
"config.example.yaml",
"style.example.yaml",
"writing-config.example.yaml",
]
# Frontmatter keys to strip (OpenClaw ignores allowed-tools)
STRIP_FRONTMATTER_KEYS = {"allowed-tools"}
def transform_frontmatter(frontmatter: str) -> str:
"""Remove Claude Code-specific frontmatter keys."""
lines = frontmatter.split("\n")
result = []
skip_block = False
for line in lines:
# Check if this line starts a key we want to strip
stripped = line.lstrip()
if any(stripped.startswith(f"{key}:") for key in STRIP_FRONTMATTER_KEYS):
skip_block = True
continue
# If we're in a skip block, skip indented continuation lines (list items)
if skip_block:
if stripped.startswith("- ") or stripped == "":
continue
skip_block = False
result.append(line)
return "\n".join(result)
def transform_body(body: str) -> str:
"""Apply all body transformations."""
# 1. {skill_dir} → {baseDir}
body = body.replace("{skill_dir}", "{baseDir}")
# 2. WebSearch references in instructions (preserve in bash code blocks)
# "WebSearch:" as instruction prefix → "web_search:"
# "WebSearch " in prose → "web_search "
body = re.sub(r'(?m)^WebSearch:', 'web_search:', body)
body = re.sub(r'(?<![`/])WebSearch(?=[ "])', 'web_search', body)
# WebSearch in parentheses/tables: "WebSearch"
body = re.sub(r'(?<=)WebSearch(?=)', 'web_search', body)
# 3. Path convention note
body = body.replace(
"本文档中 `{baseDir}` 指本 SKILL.md 所在的目录(即 WeWrite 的根目录)",
"本文档中 `{baseDir}` 指本 SKILL.md 所在的目录(即 WeWrite 的根目录)",
)
return body
def split_frontmatter(text: str) -> tuple[str, str]:
"""Split YAML frontmatter from body. Returns (frontmatter, body)."""
if not text.startswith("---"):
return "", text
end = text.find("\n---", 3)
if end == -1:
return "", text
# +4 to skip the closing "---\n"
fm = text[3:end].strip()
body = text[end + 4:] # skip "\n---"
return fm, body
def build(output_dir: Path):
skill_src = REPO_ROOT / "SKILL.md"
text = skill_src.read_text(encoding="utf-8")
fm, body = split_frontmatter(text)
fm = transform_frontmatter(fm)
body = transform_body(body)
out_skill = output_dir / "SKILL.md"
output_dir.mkdir(parents=True, exist_ok=True)
out_skill.write_text(f"---\n{fm}\n---{body}", encoding="utf-8")
print(f" SKILL.md → {out_skill}")
# Copy supporting directories
for d in COPY_DIRS:
src = REPO_ROOT / d
dst = output_dir / d
if src.is_dir():
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(src, dst)
print(f" {d}/ → {dst}")
# Copy supporting files
for f in COPY_FILES:
src = REPO_ROOT / f
if src.is_file():
shutil.copy2(src, output_dir / f)
print(f" {f}{output_dir / f}")
print(f"\nDone. OpenClaw skill at: {output_dir}")
def main():
parser = argparse.ArgumentParser(description="Build OpenClaw-compatible WeWrite skill")
parser.add_argument(
"-o", "--output",
default=str(REPO_ROOT / "dist" / "openclaw"),
help="Output directory (default: dist/openclaw/)",
)
args = parser.parse_args()
build(Path(args.output))
if __name__ == "__main__":
main()

198
dist/openclaw/scripts/build_playbook.py vendored Normal file
View file

@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""
Build a writing playbook from historical articles.
Reads all .md files in corpus/, analyzes writing patterns
in batches via LLM, and outputs a structured playbook.md.
Usage:
python3 build_playbook.py
python3 build_playbook.py --batch-size 10
Requires: ANTHROPIC_API_KEY or ARK API key in environment/config.
This script outputs analysis prompts to stdout for the Agent (LLM) to process.
The Agent reads the output and generates playbook.md.
"""
import argparse
import json
import sys
from pathlib import Path
SKILL_DIR = Path(__file__).parent.parent
def load_corpus() -> list[dict]:
"""Load all markdown files from corpus directory."""
corpus_dir = SKILL_DIR / "corpus"
if not corpus_dir.exists():
print(f"Error: corpus directory not found: {corpus_dir}", file=sys.stderr)
sys.exit(1)
articles = []
for md_file in sorted(corpus_dir.glob("*.md")):
text = md_file.read_text(encoding="utf-8")
if not text.strip():
continue
# Extract title (first H1)
title = ""
for line in text.split("\n"):
if line.strip().startswith("# ") and not line.strip().startswith("## "):
title = line.strip()[2:].strip()
break
# Basic stats
lines = [l for l in text.split("\n") if l.strip()]
paragraphs = text.split("\n\n")
h2_count = sum(1 for l in text.split("\n") if l.strip().startswith("## "))
char_count = len(text.replace("\n", "").replace(" ", ""))
articles.append({
"filename": md_file.name,
"title": title,
"char_count": char_count,
"paragraph_count": len([p for p in paragraphs if p.strip()]),
"h2_count": h2_count,
"text": text,
})
return articles
def compute_corpus_stats(articles: list[dict]) -> dict:
"""Compute aggregate statistics from the corpus."""
if not articles:
return {}
titles = [a["title"] for a in articles if a["title"]]
title_lengths = [len(t) for t in titles]
char_counts = [a["char_count"] for a in articles]
para_counts = [a["paragraph_count"] for a in articles]
h2_counts = [a["h2_count"] for a in articles]
return {
"total_articles": len(articles),
"avg_char_count": round(sum(char_counts) / len(char_counts)),
"avg_title_length": round(sum(title_lengths) / len(title_lengths), 1) if title_lengths else 0,
"title_length_range": f"{min(title_lengths)}-{max(title_lengths)}" if title_lengths else "N/A",
"avg_paragraphs": round(sum(para_counts) / len(para_counts), 1),
"avg_h2_count": round(sum(h2_counts) / len(h2_counts), 1),
}
def build_analysis_batches(articles: list[dict], batch_size: int) -> list[list[dict]]:
"""Split articles into batches for LLM analysis."""
batches = []
for i in range(0, len(articles), batch_size):
batch = articles[i:i + batch_size]
batches.append(batch)
return batches
def output_analysis_prompt(articles: list[dict], stats: dict, batch_idx: int, total_batches: int):
"""Output a structured analysis prompt for the Agent to process."""
print(f"\n{'='*60}")
print(f"BATCH {batch_idx + 1}/{total_batches}{len(articles)} articles")
print(f"{'='*60}\n")
for i, article in enumerate(articles):
print(f"--- Article {i+1}: {article['title']} ({article['char_count']}字) ---")
# Truncate very long articles to first 2000 chars for analysis
text = article["text"]
if len(text) > 3000:
text = text[:3000] + "\n\n[...truncated...]"
print(text)
print()
def main():
parser = argparse.ArgumentParser(description="Build writing playbook from corpus")
parser.add_argument("--batch-size", type=int, default=10, help="Articles per batch")
parser.add_argument("--stats-only", action="store_true", help="Only show corpus stats")
args = parser.parse_args()
# Load corpus
articles = load_corpus()
if not articles:
print("Error: no articles found in corpus/", file=sys.stderr)
sys.exit(1)
# Compute stats
stats = compute_corpus_stats(articles)
print("=" * 60)
print("CORPUS ANALYSIS")
print("=" * 60)
print(json.dumps(stats, ensure_ascii=False, indent=2))
if args.stats_only:
return
# Build batches
batches = build_analysis_batches(articles, args.batch_size)
print(f"\nTotal: {stats['total_articles']} articles in {len(batches)} batch(es)")
print(f"Average: {stats['avg_char_count']} chars, {stats['avg_title_length']} char titles, {stats['avg_h2_count']} H2s")
# Output analysis instructions
print(f"""
{'='*60}
ANALYSIS INSTRUCTIONS FOR AGENT
{'='*60}
Read all articles below, then generate playbook.md with these sections:
## 标题模式
- 平均字数和范围
- 常用策略分布数字/反直觉/痛点/疑问/陈述给百分比
- 标点习惯逗号断句问号感叹号
- 示例列出 3 个最典型的标题
## 开头模式
- 最常用的开头方式场景/数据/反问/新闻引述/个人经历
- 第一段平均长度
- 从不出现的开头方式
- 示例列出 3 个典型开头的第一段
## 段落节奏
- 平均段落长度字数
- 短段<30占比
- 最长段落上限
- 长短交替规律
## 用词指纹
- 高频标志词/口头禅出现 3 次以上的特征性表达
- 禁用词从未使用的常见 AI 用语
- 英文/专业术语使用习惯
- 语气词偏好
## H2 命名习惯
- 用问句短语数字编号
- 平均长度
- 示例
## 结尾模式
- 收尾方式个人观点/开放提问/行动建议/金句
- CTA 风格
- 示例
## 情绪基调
- 理性 vs 感性的比例
- 幽默频率
- 批判性强度
## 配图风格(如果历史文章有配图描述)
- 色调偏好
- 风格关键词
请用量化数据百分比平均值范围支撑每个结论不要只做定性描述
""")
# Output article batches
for i, batch in enumerate(batches):
output_analysis_prompt(batch, stats, i, len(batches))
if __name__ == "__main__":
main()

180
dist/openclaw/scripts/fetch_hotspots.py vendored Normal file
View file

@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""
Fetch trending topics from multiple Chinese platforms.
Sources (all attempted in parallel, results merged and deduplicated):
1. Weibo hot search (weibo.com/ajax/side/hotSearch)
2. Toutiao hot board (toutiao.com/hot-event/hot-board)
3. Baidu hot search (top.baidu.com/api/board)
Usage:
python3 fetch_hotspots.py --limit 20
"""
import argparse
import json
import sys
from datetime import datetime, timezone, timedelta
import requests
TIMEOUT = 10
HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36",
"Accept": "application/json, text/plain, */*",
}
def fetch_weibo() -> list[dict]:
"""Fetch Weibo hot search."""
try:
resp = requests.get(
"https://weibo.com/ajax/side/hotSearch",
headers={**HEADERS, "Referer": "https://weibo.com/"},
timeout=TIMEOUT,
)
data = resp.json()
items = []
for entry in data.get("data", {}).get("realtime", []):
note = entry.get("note", "")
if not note:
continue
items.append({
"title": note,
"source": "微博",
"hot": entry.get("num", 0),
"url": f"https://s.weibo.com/weibo?q=%23{note}%23",
"description": entry.get("label_name", ""),
})
return items
except Exception as e:
print(f"[warn] weibo failed: {e}", file=sys.stderr)
return []
def fetch_toutiao() -> list[dict]:
"""Fetch Toutiao hot board."""
try:
resp = requests.get(
"https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc",
headers=HEADERS,
timeout=TIMEOUT,
)
data = resp.json()
items = []
for entry in data.get("data", []):
title = entry.get("Title", "")
if not title:
continue
items.append({
"title": title,
"source": "今日头条",
"hot": int(entry.get("HotValue", 0) or 0),
"url": entry.get("Url", ""),
"description": "",
})
return items
except Exception as e:
print(f"[warn] toutiao failed: {e}", file=sys.stderr)
return []
def fetch_baidu() -> list[dict]:
"""Fetch Baidu hot search."""
try:
resp = requests.get(
"https://top.baidu.com/api/board?platform=wise&tab=realtime",
headers=HEADERS,
timeout=TIMEOUT,
)
data = resp.json()
items = []
# Baidu nests items inside cards[0].content[0].content
for card in data.get("data", {}).get("cards", []):
top_content = card.get("content", [])
if not top_content:
continue
entries = top_content[0].get("content", []) if isinstance(top_content[0], dict) else top_content
for entry in entries:
word = entry.get("word", "")
if not word:
continue
items.append({
"title": word,
"source": "百度",
"hot": int(entry.get("hotScore", 0) or 0),
"url": entry.get("url", ""),
"description": "",
})
return items
except Exception as e:
print(f"[warn] baidu failed: {e}", file=sys.stderr)
return []
def deduplicate(items: list[dict]) -> list[dict]:
"""Remove duplicates by exact title match."""
seen = set()
result = []
for item in items:
title = item["title"].strip()
if title and title not in seen:
seen.add(title)
result.append(item)
return result
def main():
parser = argparse.ArgumentParser(description="Fetch trending topics")
parser.add_argument("--limit", type=int, default=20, help="Max items to return")
args = parser.parse_args()
all_items = []
sources_ok = []
sources_fail = []
for name, fetcher in [("weibo", fetch_weibo), ("toutiao", fetch_toutiao), ("baidu", fetch_baidu)]:
items = fetcher()
if items:
sources_ok.append(name)
all_items.extend(items)
else:
sources_fail.append(name)
all_items = deduplicate(all_items)
# Normalize hot values across platforms (different scales: toutiao ~10M, weibo ~1M, baidu ~100K)
# Strategy: within each source, rank-based score 0-100, so cross-platform sorting is fair
by_source: dict[str, list[dict]] = {}
for item in all_items:
by_source.setdefault(item["source"], []).append(item)
for source, items in by_source.items():
items.sort(key=lambda x: int(x.get("hot", 0) or 0), reverse=True)
n = len(items)
for rank, item in enumerate(items):
# Top item = 100, linear decay to ~1 for last item
item["hot_normalized"] = round(100 * (n - rank) / n, 1) if n > 0 else 0
all_items.sort(key=lambda x: x.get("hot_normalized", 0), reverse=True)
all_items = all_items[:args.limit]
tz = timezone(timedelta(hours=8))
output = {
"timestamp": datetime.now(tz).isoformat(),
"sources": sources_ok,
"sources_failed": sources_fail,
"count": len(all_items),
"items": all_items,
}
if not all_items:
output["error"] = "All sources failed. SKILL.md should fall back to WebSearch."
json.dump(output, sys.stdout, ensure_ascii=False, indent=2)
if __name__ == "__main__":
main()

179
dist/openclaw/scripts/fetch_stats.py vendored Normal file
View file

@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""
Fetch WeChat article statistics and update history.yaml.
Uses WeChat Data Analytics API to pull article performance:
- /datacube/getarticlesummary (daily summary)
- /datacube/getarticletotal (cumulative)
Usage:
python3 fetch_stats.py
python3 fetch_stats.py --days 7
Requires: wechat appid/secret in config.yaml (skill root or toolkit dir)
"""
import argparse
import json
import sys
from datetime import datetime, timedelta
from pathlib import Path
import requests
import yaml
SKILL_DIR = Path(__file__).parent.parent
TOOLKIT_CONFIG_PATHS = [
SKILL_DIR / "config.yaml", # skill root
SKILL_DIR / "toolkit" / "config.yaml", # toolkit dir
Path.home() / ".config" / "wewrite" / "config.yaml",
Path.cwd() / "config.yaml",
]
def _load_toolkit_config() -> dict:
for p in TOOLKIT_CONFIG_PATHS:
if p.exists():
with open(p, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
return {}
def _get_access_token(appid: str, secret: str) -> str:
resp = requests.get(
"https://api.weixin.qq.com/cgi-bin/token",
params={"grant_type": "client_credential", "appid": appid, "secret": secret},
)
data = resp.json()
if "access_token" not in data:
raise ValueError(f"Token error: {data}")
return data["access_token"]
def fetch_article_summary(token: str, date: str) -> list[dict]:
"""
Fetch daily article summary.
API: POST /datacube/getarticlesummary
date format: "2026-03-23"
"""
resp = requests.post(
"https://api.weixin.qq.com/datacube/getarticlesummary",
params={"access_token": token},
json={"begin_date": date, "end_date": date},
)
data = resp.json()
if "list" not in data:
errcode = data.get("errcode", "unknown")
errmsg = data.get("errmsg", "")
if errcode == 61500:
# No data for this date (article not yet published or no reads)
return []
print(f"[warn] getarticlesummary error: {errcode} {errmsg}", file=sys.stderr)
return []
return data["list"]
def fetch_article_total(token: str, date: str) -> list[dict]:
"""
Fetch cumulative article stats.
API: POST /datacube/getarticletotal
"""
resp = requests.post(
"https://api.weixin.qq.com/datacube/getarticletotal",
params={"access_token": token},
json={"begin_date": date, "end_date": date},
)
data = resp.json()
if "list" not in data:
return []
return data["list"]
def update_history(stats_list: list[dict]):
"""Match stats to history.yaml entries and update."""
history_path = SKILL_DIR / "history.yaml"
if not history_path.exists():
print("No history.yaml found.")
return
with open(history_path, "r", encoding="utf-8") as f:
history = yaml.safe_load(f) or {}
articles = history.get("articles", [])
if not articles:
print("No articles in history to update.")
return
# Build a lookup by title for matching
title_to_idx = {}
for i, article in enumerate(articles):
title_to_idx[article.get("title", "")] = i
updated = 0
for stat in stats_list:
title = stat.get("title", "")
if title in title_to_idx:
idx = title_to_idx[title]
articles[idx]["stats"] = {
"read_count": stat.get("int_page_read_count", 0),
"share_count": stat.get("share_count", 0),
"like_count": stat.get("old_like_count", 0) + stat.get("like_count", 0),
"read_rate": round(
stat.get("int_page_read_count", 0)
/ max(stat.get("target_user", 1), 1)
* 100,
1,
),
}
updated += 1
if updated > 0:
history["articles"] = articles
with open(history_path, "w", encoding="utf-8") as f:
yaml.dump(history, f, allow_unicode=True, default_flow_style=False)
print(f"Updated stats for {updated} article(s).")
else:
print("No matching articles found in stats data.")
def main():
parser = argparse.ArgumentParser(description="Fetch WeChat article stats")
parser.add_argument("--days", type=int, default=3, help="Days to look back")
args = parser.parse_args()
cfg = _load_toolkit_config()
wechat_cfg = cfg.get("wechat", {})
appid = wechat_cfg.get("appid")
secret = wechat_cfg.get("secret")
if not appid or not secret:
print("Error: wechat appid/secret not found in config.yaml", file=sys.stderr)
sys.exit(1)
token = _get_access_token(appid, secret)
print(f"Fetching stats for last {args.days} days...")
all_stats = []
for i in range(args.days):
date = (datetime.now() - timedelta(days=i + 1)).strftime("%Y-%m-%d")
stats = fetch_article_summary(token, date)
if stats:
print(f" {date}: {len(stats)} article(s)")
all_stats.extend(stats)
if all_stats:
update_history(all_stats)
else:
print("No stats data found for the specified period.")
# Also print summary
print(f"\nTotal data points: {len(all_stats)}")
for s in all_stats:
title = s.get("title", "unknown")
reads = s.get("int_page_read_count", 0)
shares = s.get("share_count", 0)
print(f" [{reads} reads, {shares} shares] {title}")
if __name__ == "__main__":
main()

299
dist/openclaw/scripts/humanness_score.py vendored Normal file
View file

@ -0,0 +1,299 @@
#!/usr/bin/env python3
"""
Fixed humanness scoring pipeline for WeWrite optimization loop.
Two-layer scoring inspired by autoresearch + the "objective checklist + subjective feel" pattern:
Layer 1: Objective checklist (yes/no, deterministic, won't drift)
Layer 2: Subjective reader-feel (LLM judge, 1-10)
Composite = Layer1 pass_rate * 0.6 + Layer2 normalized * 0.4
DO NOT MODIFY this file during optimization. It is the fixed evaluation function.
Usage:
python3 humanness_score.py article.md
python3 humanness_score.py article.md --verbose
python3 humanness_score.py article.md --json
"""
import argparse
import json
import re
import sys
from pathlib import Path
# ============================================================
# Layer 1: Objective Checklist (deterministic yes/no)
# ============================================================
BANNED_WORDS = [
"首先", "其次", "再者", "最后", "总之", "综上所述", "总而言之",
"此外", "另外", "与此同时", "不仅如此", "更重要的是", "在此基础上",
"作为一个", "让我们", "值得注意的是", "需要指出的是", "不可否认",
"毋庸置疑", "众所周知", "事实上", "显而易见", "可以说", "从某种意义上说",
"非常重要", "至关重要", "不言而喻", "具有重要意义", "发挥着重要作用",
"意义深远", "影响深远", "引发了广泛关注", "引起了热烈讨论",
"总的来说", "综合来看", "由此可见", "不难发现", "通过以上分析",
"正如我们所看到的",
]
# Real-source indicators: named people, organizations, specific publications
REAL_SOURCE_PATTERNS = [
r'[A-Z][a-z]+\s+[A-Z][a-z]+', # Named person (English)
r'[\u4e00-\u9fff]{2,4}(?:表示|指出|认为|写道|提到|说过)', # Chinese name + said
r'(?:据|根据|来自)\s*[\u4e00-\u9fff]+(?:报告|数据|研究|调查)', # "according to X report"
r'20[12]\d\s*年', # Specific year reference
r'\d+(?:\.\d+)?%', # Specific percentage
r'(?:亿|万)\s*(?:美元|元|人民币)', # Specific monetary amount
]
def check_no_banned_words(text: str) -> tuple[bool, str]:
"""Check: zero banned words."""
found = [w for w in BANNED_WORDS if w in text]
if found:
return False, f"Found {len(found)} banned words: {found[:5]}"
return True, "0 banned words"
def check_real_sources(text: str) -> tuple[bool, str]:
"""Check: article references real external sources (≥3 instances)."""
count = 0
for pattern in REAL_SOURCE_PATTERNS:
count += len(re.findall(pattern, text))
if count >= 3:
return True, f"{count} real-source indicators found"
return False, f"Only {count} real-source indicators (need ≥3)"
def check_broken_sentences(text: str) -> tuple[bool, str]:
"""Check: ≥3 broken/incomplete sentences (dashes, ellipsis, self-corrections)."""
patterns = [
r'——(?!.*[,。!?])', # em-dash interruption without ending punct
r'\.{3,}|…', # ellipsis
r'不对[,]', # self-correction "不对,"
r'算了', # abandonment "算了"
r'^.{1,6}[。!?]$', # ultra-short sentence (≤6 chars + punct) as standalone line
]
count = 0
lines = text.split('\n')
for line in lines:
line = line.strip()
if not line:
continue
for p in patterns:
count += len(re.findall(p, line))
# Check for ultra-short standalone paragraphs (1-10 chars)
if 1 <= len(line) <= 10 and not line.startswith('#'):
count += 1
if count >= 3:
return True, f"{count} broken/incomplete structures"
return False, f"Only {count} broken structures (need ≥3)"
def check_sentence_length_variance(text: str) -> tuple[bool, str]:
"""Check: sentence length standard deviation > threshold.
AI text has suspiciously uniform sentence lengths.
Human text varies wildly (3-char to 80-char sentences in the same paragraph).
"""
# Split by Chinese sentence-ending punctuation
sentences = re.split(r'[。!?\n]', text)
sentences = [s.strip() for s in sentences if s.strip() and len(s.strip()) > 1]
if len(sentences) < 5:
return False, "Too few sentences to measure"
lengths = [len(s) for s in sentences]
mean = sum(lengths) / len(lengths)
variance = sum((l - mean) ** 2 for l in lengths) / len(lengths)
stddev = variance ** 0.5
# Threshold: human text typically has stddev > 15 chars
# AI text tends to be 8-12
if stddev > 15:
return True, f"Sentence length stddev = {stddev:.1f} (good variance)"
return False, f"Sentence length stddev = {stddev:.1f} (too uniform, need >15)"
def check_paragraph_length_variance(text: str) -> tuple[bool, str]:
"""Check: no consecutive paragraphs of similar length."""
paragraphs = [p.strip() for p in text.split('\n\n') if p.strip() and not p.strip().startswith('#')]
if len(paragraphs) < 3:
return True, "Too few paragraphs to check"
consecutive_similar = 0
for i in range(len(paragraphs) - 1):
len_a = len(paragraphs[i])
len_b = len(paragraphs[i + 1])
if abs(len_a - len_b) <= 20:
consecutive_similar += 1
if consecutive_similar <= 1:
return True, f"{consecutive_similar} consecutive similar-length pairs (OK)"
return False, f"{consecutive_similar} consecutive similar-length pairs (too uniform)"
def check_word_temperature_mix(text: str) -> tuple[bool, str]:
"""Check: mix of formal/colloquial/slang/wild vocabulary."""
cold = ["边际", "认知负荷", "信息不对称", "路径依赖", "商业模式", "生态系统", "增量"]
warm = ["说白了", "其实吧", "讲真", "说实话", "坦白讲", "懂的都懂", "怎么说呢"]
hot = ["DNA动了", "格局打开", "遥遥领先", "", "内卷", "炸了", "杀疯了", "吃灰"]
wild = ["整挺好", "不靠谱", "瞎折腾", "搁这儿", "糊弄", "", ""]
found_temps = 0
if any(w in text for w in cold): found_temps += 1
if any(w in text for w in warm): found_temps += 1
if any(w in text for w in hot): found_temps += 1
if any(w in text for w in wild): found_temps += 1
if found_temps >= 3:
return True, f"{found_temps}/4 temperature types found"
return False, f"Only {found_temps}/4 temperature types (need ≥3)"
def run_layer1(text: str) -> dict:
"""Run all Layer 1 checks. Returns dict with results."""
checks = [
("no_banned_words", check_no_banned_words),
("real_sources", check_real_sources),
("broken_sentences", check_broken_sentences),
("sentence_length_variance", check_sentence_length_variance),
("paragraph_length_variance", check_paragraph_length_variance),
("word_temperature_mix", check_word_temperature_mix),
]
results = {}
passed = 0
total = len(checks)
for name, fn in checks:
ok, detail = fn(text)
results[name] = {"passed": ok, "detail": detail}
if ok:
passed += 1
results["_summary"] = {
"passed": passed,
"total": total,
"pass_rate": round(passed / total, 4),
}
return results
# ============================================================
# Layer 2: Subjective Reader-Feel (LLM judge)
# ============================================================
JUDGE_PROMPT = """你是一个经验丰富的公众号读者。你对 AI 生成的内容非常敏感——你能凭直觉分辨出人写的和 AI 写的。
现在请你阅读下面这篇文章然后回答一个问题
**这篇文章读起来像人写的吗**
评分标准1-10
- 1-3明显是 AI 写的语言过于规整没有个人色彩像教科书
- 4-5有一些 AI 痕迹整体流畅但缺乏个人经历情感起伏或出人意料的表达
- 6-7大部分像人写的偶尔有几句感觉"太完美了"
- 8-9很像人写的有个人风格情感波动不完美感像一个真人编辑的作品
- 10完全像人写的如果不告诉我我不会怀疑这是 AI 参与的
请只输出一个 JSON{"score": 数字, "reason": "一句话理由"}
---
文章内容
{article}
"""
def run_layer2_stub(text: str) -> dict:
"""Layer 2 stub — returns placeholder when no LLM API available.
In production, this calls Claude/GPT to judge the article.
For the optimization loop, replace this with actual API call.
"""
return {
"score": 5.0,
"reason": "(stub) LLM judge not configured — using default score",
"is_stub": True,
}
# ============================================================
# Composite Score
# ============================================================
def compute_composite(layer1: dict, layer2: dict) -> float:
"""Composite score: lower is better (like val_bpb in autoresearch).
Inverted so that 0 = perfect human, 100 = obvious AI.
"""
l1_pass_rate = layer1["_summary"]["pass_rate"]
l2_score = layer2["score"] / 10.0 # normalize to 0-1
# Composite: higher pass_rate and higher reader score = more human
humanness = l1_pass_rate * 0.6 + l2_score * 0.4
# Invert: 0 = perfect human, 100 = obvious AI
return round((1 - humanness) * 100, 2)
# ============================================================
# Main
# ============================================================
def score_article(text: str, verbose: bool = False) -> dict:
"""Score an article. Returns full results dict."""
# Strip markdown headers for scoring
clean = re.sub(r'^#+\s+.*$', '', text, flags=re.MULTILINE).strip()
layer1 = run_layer1(clean)
layer2 = run_layer2_stub(clean)
composite = compute_composite(layer1, layer2)
result = {
"composite_score": composite,
"layer1": layer1,
"layer2": layer2,
"char_count": len(clean),
}
if verbose:
print(f"\n{'='*60}")
print(f"HUMANNESS SCORE: {composite:.1f}/100 (lower = more human)")
print(f"{'='*60}")
print(f"\nLayer 1 — Objective Checklist ({layer1['_summary']['passed']}/{layer1['_summary']['total']})")
for name, data in layer1.items():
if name.startswith('_'):
continue
status = "" if data["passed"] else ""
print(f" {status} {name}: {data['detail']}")
print(f"\nLayer 2 — Reader Feel: {layer2['score']}/10")
print(f" {layer2['reason']}")
print(f"\nComposite: {composite:.1f} (0=完美人类, 100=明显AI)")
return result
def main():
parser = argparse.ArgumentParser(description="Score article humanness")
parser.add_argument("input", help="Markdown article file")
parser.add_argument("--verbose", "-v", action="store_true", help="Detailed output")
parser.add_argument("--json", action="store_true", help="JSON output")
args = parser.parse_args()
text = Path(args.input).read_text(encoding="utf-8")
result = score_article(text, verbose=args.verbose)
if args.json:
print(json.dumps(result, ensure_ascii=False, indent=2))
elif not args.verbose:
print(f"{result['composite_score']:.1f}")
if __name__ == "__main__":
main()

274
dist/openclaw/scripts/learn_edits.py vendored Normal file
View file

@ -0,0 +1,274 @@
#!/usr/bin/env python3
"""
Learn from human edits by diffing AI draft vs published final.
Compares the original AI-generated article with the human-edited version,
categorizes the changes, and saves lessons to lessons/.
When 5+ lessons accumulate, outputs a prompt for the Agent to update playbook.md.
Usage:
python3 learn_edits.py --draft path/to/draft.md --final path/to/final.md
python3 learn_edits.py --summarize # summarize all lessons
The script does structural analysis; the Agent (LLM) interprets the diffs
and writes the lesson YAML + playbook updates.
"""
import argparse
import difflib
import json
import re
import sys
from datetime import datetime
from pathlib import Path
import yaml
SKILL_DIR = Path(__file__).parent.parent
def load_text(path: str) -> str:
return Path(path).read_text(encoding="utf-8")
def split_sections(text: str) -> list[dict]:
"""Split markdown into sections by H2 headers."""
sections = []
current = {"header": "(intro)", "lines": []}
for line in text.split("\n"):
if line.strip().startswith("## "):
if current["lines"] or current["header"] != "(intro)":
sections.append(current)
current = {"header": line.strip(), "lines": []}
else:
current["lines"].append(line)
sections.append(current)
return sections
def extract_title(text: str) -> str:
for line in text.split("\n"):
if line.strip().startswith("# ") and not line.strip().startswith("## "):
return line.strip()[2:].strip()
return ""
def compute_diff(draft: str, final: str) -> dict:
"""Compute structured diff between draft and final."""
draft_lines = draft.split("\n")
final_lines = final.split("\n")
# Line-level diff
differ = difflib.unified_diff(draft_lines, final_lines, lineterm="")
diff_lines = list(differ)
# Categorize changes
additions = []
deletions = []
for line in diff_lines:
if line.startswith("+") and not line.startswith("+++"):
additions.append(line[1:].strip())
elif line.startswith("-") and not line.startswith("---"):
deletions.append(line[1:].strip())
# Filter empty lines
additions = [l for l in additions if l]
deletions = [l for l in deletions if l]
# Title change
draft_title = extract_title(draft)
final_title = extract_title(final)
title_changed = draft_title != final_title
# Section-level analysis
draft_sections = split_sections(draft)
final_sections = split_sections(final)
draft_h2s = [s["header"] for s in draft_sections if s["header"] != "(intro)"]
final_h2s = [s["header"] for s in final_sections if s["header"] != "(intro)"]
structure_changed = draft_h2s != final_h2s
# Word count change
draft_chars = len(draft.replace("\n", "").replace(" ", ""))
final_chars = len(final.replace("\n", "").replace(" ", ""))
return {
"title_changed": title_changed,
"draft_title": draft_title,
"final_title": final_title,
"structure_changed": structure_changed,
"draft_h2s": draft_h2s,
"final_h2s": final_h2s,
"lines_added": len(additions),
"lines_deleted": len(deletions),
"draft_chars": draft_chars,
"final_chars": final_chars,
"char_diff": final_chars - draft_chars,
"additions_sample": additions[:20],
"deletions_sample": deletions[:20],
}
def save_diff_for_analysis(diff_result: dict, draft_path: str, final_path: str):
"""Save diff data for Agent to analyze and write lessons."""
lessons_dir = SKILL_DIR / "lessons"
lessons_dir.mkdir(parents=True, exist_ok=True)
date_str = datetime.now().strftime("%Y-%m-%d")
diff_file = lessons_dir / f"{date_str}-diff.yaml"
# If file exists, append a counter
counter = 1
while diff_file.exists():
diff_file = lessons_dir / f"{date_str}-diff-{counter}.yaml"
counter += 1
data = {
"date": date_str,
"draft_file": str(draft_path),
"final_file": str(final_path),
"diff_summary": {
"title_changed": diff_result["title_changed"],
"draft_title": diff_result["draft_title"],
"final_title": diff_result["final_title"],
"structure_changed": diff_result["structure_changed"],
"lines_added": diff_result["lines_added"],
"lines_deleted": diff_result["lines_deleted"],
"char_diff": diff_result["char_diff"],
},
"edits": [], # Agent fills this after analysis
"patterns": [], # Agent fills this after analysis
}
with open(diff_file, "w", encoding="utf-8") as f:
yaml.dump(data, f, allow_unicode=True, default_flow_style=False)
return diff_file
def count_lessons() -> int:
"""Count existing lesson files."""
lessons_dir = SKILL_DIR / "lessons"
if not lessons_dir.exists():
return 0
return len(list(lessons_dir.glob("*-diff*.yaml")))
def summarize_lessons():
"""Load all lessons and output for Agent to update playbook."""
lessons_dir = SKILL_DIR / "lessons"
if not lessons_dir.exists():
print("No lessons directory found.")
return
lesson_files = sorted(lessons_dir.glob("*-diff*.yaml"))
if not lesson_files:
print("No lessons found.")
return
all_lessons = []
for f in lesson_files:
with open(f, "r", encoding="utf-8") as fh:
data = yaml.safe_load(fh)
if data:
all_lessons.append(data)
print(f"Total lessons: {len(all_lessons)}")
print(json.dumps(all_lessons, ensure_ascii=False, indent=2))
def main():
parser = argparse.ArgumentParser(description="Learn from human edits")
parser.add_argument("--draft", help="Path to AI draft")
parser.add_argument("--final", help="Path to human-edited final")
parser.add_argument("--summarize", action="store_true", help="Summarize all lessons")
args = parser.parse_args()
if args.summarize:
summarize_lessons()
return
if not args.draft or not args.final:
print("Error: --draft and --final required", file=sys.stderr)
sys.exit(1)
# Load texts
draft = load_text(args.draft)
final = load_text(args.final)
# Compute diff
diff_result = compute_diff(draft, final)
# Print summary
print("=" * 60)
print("EDIT ANALYSIS")
print("=" * 60)
if diff_result["title_changed"]:
print(f"\n标题修改:")
print(f" AI: {diff_result['draft_title']}")
print(f" 人工: {diff_result['final_title']}")
if diff_result["structure_changed"]:
print(f"\n结构修改:")
print(f" AI H2: {diff_result['draft_h2s']}")
print(f" 人工 H2: {diff_result['final_h2s']}")
print(f"\n数量变化:")
print(f" 新增 {diff_result['lines_added']} 行, 删除 {diff_result['lines_deleted']}")
print(f" 字数变化: {diff_result['char_diff']:+d} ({diff_result['draft_chars']}{diff_result['final_chars']})")
if diff_result["deletions_sample"]:
print(f"\n被删除的内容(采样):")
for line in diff_result["deletions_sample"][:10]:
print(f" - {line[:80]}")
if diff_result["additions_sample"]:
print(f"\n新增的内容(采样):")
for line in diff_result["additions_sample"][:10]:
print(f" + {line[:80]}")
# Save for Agent analysis
diff_file = save_diff_for_analysis(diff_result, args.draft, args.final)
print(f"\nDiff saved to: {diff_file}")
# Check if playbook update should be triggered
lesson_count = count_lessons()
print(f"Total lessons: {lesson_count}")
if lesson_count >= 5 and lesson_count % 5 == 0:
print(f"\n{'='*60}")
print("PLAYBOOK UPDATE TRIGGERED")
print(f"{'='*60}")
print(f"{lesson_count} lessons accumulated. Agent should:")
print(f"1. Read all lessons: python3 learn_edits.py --summarize")
print(f"2. Read current playbook: playbook.md")
print(f"3. Update playbook with recurring patterns from lessons")
# Output instructions for Agent
print(f"""
{'='*60}
INSTRUCTIONS FOR AGENT
{'='*60}
Read the draft and final versions, then analyze the edits:
1. Read: {args.draft}
2. Read: {args.final}
3. For each meaningful edit, classify it:
- type: "用词替换" / "段落删除" / "段落新增" / "结构调整" / "标题修改" / "语气调整"
- before: (original text)
- after: (edited text)
- pattern: (what this tells us about the user's preference)
4. Update {diff_file} with the edits and patterns lists.
5. If this is a recurring pattern (seen in previous lessons too),
consider updating playbook.md.
""")
if __name__ == "__main__":
main()

149
dist/openclaw/scripts/optimize_loop.py vendored Normal file
View file

@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""
WeWrite Optimization Loop autoresearch-style iterative improvement.
Inspired by Karpathy's autoresearch: change → score → keep/rollback → repeat.
But instead of optimizing ML training code, we optimize WRITING RULES to
produce articles that pass AI detection while maintaining quality.
The mutable surface: writing-config.yaml (style parameters + prompt rules)
The fixed evaluation: humanness_score.py (objective checklist + subjective feel)
The metric: composite_score (lower = more human, like val_bpb)
Usage:
python3 optimize_loop.py --topic "AI Agent" --iterations 10
python3 optimize_loop.py --topic "AI Agent" --iterations 5 --verbose
Architecture:
1. Load current writing-config.yaml
2. Generate article with current config
3. Score with humanness_score.py
4. LLM proposes a change to writing-config.yaml
5. Generate article with new config
6. Score again
7. If improved keep (commit). If not rollback.
8. Log to results.tsv
9. Repeat.
Requirements:
- ANTHROPIC_API_KEY in environment (for article generation + LLM judge)
- writing-config.yaml in skill root (created on first run with defaults)
"""
import argparse
import json
import os
import subprocess
import sys
from datetime import datetime
from pathlib import Path
import yaml
SKILL_DIR = Path(__file__).parent.parent
CONFIG_PATH = SKILL_DIR / "writing-config.yaml"
RESULTS_PATH = SKILL_DIR / "optimization-results.tsv"
DEFAULT_CONFIG = {
"persona": "科技媒体资深编辑写了八年公众号对AI行业有深度认知",
"sentence_variance": 0.7,
"broken_sentence_rate": 0.04,
"idiom_density": 0.15,
"filler_style": "mixed", # literary / casual / mixed / minimal
"paragraph_rhythm": "chaotic", # structured / chaotic / wave
"self_correction_rate": 0.02,
"tangent_frequency": "every_800_chars", # never / every_500 / every_800 / every_1200
"real_data_density": "high", # low / medium / high
"word_temperature_bias": "warm", # cold / warm / hot / balanced
"emotional_arc": "restrained_to_burst", # flat / gradual / restrained_to_burst / volatile
"opening_style": "scene", # scene / data / question / anecdote / cold_open
"closing_style": "open_question", # summary / open_question / image / abrupt
"structure_linearity": 0.3, # 0=fully non-linear, 1=fully linear
}
def ensure_config():
"""Create default writing-config.yaml if it doesn't exist."""
if not CONFIG_PATH.exists():
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
yaml.dump(DEFAULT_CONFIG, f, allow_unicode=True, default_flow_style=False)
print(f"Created default config: {CONFIG_PATH}")
return yaml.safe_load(CONFIG_PATH.read_text(encoding="utf-8"))
def score_article(article_path: str) -> dict:
"""Run humanness_score.py on an article. Returns parsed result."""
result = subprocess.run(
["python3", str(SKILL_DIR / "scripts" / "humanness_score.py"), article_path, "--json"],
capture_output=True, text=True
)
if result.returncode != 0:
print(f"Scoring failed: {result.stderr}", file=sys.stderr)
return {"composite_score": 100.0, "error": result.stderr}
return json.loads(result.stdout)
def log_result(iteration: int, composite: float, config_summary: str, status: str, description: str):
"""Append result to TSV log."""
header_needed = not RESULTS_PATH.exists()
with open(RESULTS_PATH, "a", encoding="utf-8") as f:
if header_needed:
f.write("iteration\ttimestamp\tcomposite\tstatus\tdescription\tconfig_change\n")
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
f.write(f"{iteration}\t{ts}\t{composite:.2f}\t{status}\t{description}\t{config_summary}\n")
def print_banner(iteration: int, total: int):
print(f"\n{'='*60}")
print(f" OPTIMIZATION LOOP — Iteration {iteration}/{total}")
print(f"{'='*60}")
def main():
parser = argparse.ArgumentParser(description="WeWrite optimization loop")
parser.add_argument("--topic", required=True, help="Article topic for testing")
parser.add_argument("--iterations", type=int, default=10, help="Number of iterations")
parser.add_argument("--verbose", "-v", action="store_true")
args = parser.parse_args()
print(f"""
WeWrite Optimization Loop
Topic: {args.topic:<44s}
Iterations: {args.iterations:<39d}
Pattern: change config generate score
keep if better, rollback if worse
""")
config = ensure_config()
print("This script provides the FRAMEWORK for optimization.")
print("To run the full loop, you need:")
print(" 1. An article generation function (Claude API)")
print(" 2. A scoring function (humanness_score.py — included)")
print(" 3. An LLM to propose config changes (Claude API)")
print()
print("Current config:")
print(yaml.dump(config, allow_unicode=True, default_flow_style=False))
print()
print("Run this loop via Claude Code / OpenClaw agent:")
print()
print(" Agent reads writing-config.yaml")
print(" → generates article with those rules")
print(" → scores with: python3 scripts/humanness_score.py article.md --json")
print(" → proposes a config change")
print(" → generates new article")
print(" → scores again")
print(" → if composite_score decreased → commit config change")
print(" → if composite_score same/worse → rollback")
print(" → logs to optimization-results.tsv")
print(" → repeats")
print()
print("To test scoring on an existing article:")
print(f" python3 scripts/humanness_score.py <article.md> --verbose")
if __name__ == "__main__":
main()

119
dist/openclaw/scripts/seo_keywords.py vendored Normal file
View file

@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""
SEO keyword research tool.
Queries real search data to evaluate keyword popularity:
1. Baidu search suggestions (autocomplete volume proxy)
2. Baidu related searches
3. WeChat sogou index (search volume proxy)
Usage:
python3 seo_keywords.py "AI大模型"
python3 seo_keywords.py "AI大模型" "科技股" "创业"
python3 seo_keywords.py --json "AI大模型"
Output: keyword popularity score, related keywords, trending signals.
"""
import argparse
import json
import sys
import urllib.parse
import requests
TIMEOUT = 10
HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36",
}
def baidu_suggestions(keyword: str) -> list[str]:
"""Get Baidu search autocomplete suggestions — proxy for search volume."""
try:
resp = requests.get(
"https://suggestion.baidu.com/su",
params={"wd": keyword, "action": "opensearch", "ie": "utf-8"},
headers=HEADERS,
timeout=TIMEOUT,
)
data = resp.json()
# Response format: [query, [suggestions...]]
if isinstance(data, list) and len(data) >= 2:
return data[1]
return []
except Exception as e:
print(f"[warn] baidu suggestions failed: {e}", file=sys.stderr)
return []
def so360_suggestions(keyword: str) -> list[str]:
"""Get 360 search suggestions — second source for search volume proxy."""
try:
resp = requests.get(
"https://sug.so.360.cn/suggest",
params={"word": keyword, "encodein": "utf-8", "encodeout": "utf-8", "format": "json"},
headers=HEADERS,
timeout=TIMEOUT,
)
data = resp.json()
return [item.get("word", "") for item in data.get("result", []) if item.get("word")]
except Exception as e:
print(f"[warn] 360 suggestions failed: {e}", file=sys.stderr)
return []
def analyze_keyword(keyword: str) -> dict:
"""Analyze a keyword's SEO potential."""
baidu_suggs = baidu_suggestions(keyword)
so360_suggs = so360_suggestions(keyword)
# Popularity score (0-10) based on suggestion count
# More suggestions = more search demand
baidu_score = min(len(baidu_suggs), 10)
so360_score = min(len(so360_suggs), 10)
# Combined score: average of two sources
combined_score = round((baidu_score + so360_score) / 2, 1)
# Extract related keywords (dedup)
all_related = list(dict.fromkeys(baidu_suggs + so360_suggs))
return {
"keyword": keyword,
"seo_score": combined_score,
"baidu_score": baidu_score,
"so360_score": so360_score,
"baidu_suggestions": baidu_suggs[:5],
"so360_suggestions": so360_suggs[:5],
"related_keywords": all_related[:10],
}
def main():
parser = argparse.ArgumentParser(description="SEO keyword analysis")
parser.add_argument("keywords", nargs="+", help="Keywords to analyze")
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
results = []
for kw in args.keywords:
result = analyze_keyword(kw)
results.append(result)
if args.json:
json.dump(results, sys.stdout, ensure_ascii=False, indent=2)
else:
for r in results:
print(f"\n关键词: {r['keyword']}")
print(f" 综合 SEO 评分: {r['seo_score']}/10百度 {r['baidu_score']} + 360 {r['so360_score']}")
if r["so360_suggestions"]:
print(f" 360热搜词: {', '.join(r['so360_suggestions'][:5])}")
if r["related_keywords"]:
print(f" 相关关键词: {', '.join(r['related_keywords'][:5])}")
if __name__ == "__main__":
main()

49
dist/openclaw/style.example.yaml vendored Normal file
View file

@ -0,0 +1,49 @@
# WeWrite 风格配置
# 复制为 style.yaml 并修改为你的公众号信息
# 或让 WeWrite 在首次使用时通过对话自动生成
name: "Demo科技"
industry: "科技/互联网"
target_audience: "25-40岁互联网从业者、科技爱好者"
# 内容方向
topics:
- AI/人工智能
- 产品设计
- 创业/商业模式
- 效率工具
# 写作风格
tone: "专业但不学术,有观点但不偏激,偶尔幽默"
voice: "第一人称,像一个懂行的朋友在分享见解"
word_count: "1500-2500"
# 内容风格(干货/故事/情绪/热点/测评)
# 影响选题偏好和框架推荐
content_style: "干货"
# 写作人格(决定文章语感和 AI 检测通过率)
# 可选midnight-friend / industry-observer / sharp-journalist / warm-editor / cold-analyst
# 详见 personas/ 目录
writing_persona: "midnight-friend"
# 禁忌
blacklist:
words: ["震惊", "必看", "不转不是中国人", "赶紧收藏"]
topics: ["政治敏感", "宗教", "色情", "赌博"]
# 参考账号风格
reference_accounts:
- "36氪"
- "虎嗅"
- "少数派"
# 排版
theme: "professional-clean"
# 封面
cover_style: "简洁科技感,蓝色调,扁平化设计"
# cover_template: "" # 设置后跳过 AI 生成,直接使用该文件
# 署名
author: "Demo编辑部"

419
dist/openclaw/toolkit/cli.py vendored Normal file
View file

@ -0,0 +1,419 @@
#!/usr/bin/env python3
"""
CLI entry point for WeWrite.
Usage:
python cli.py preview article.md --theme professional-clean
python cli.py publish article.md --appid wx123 --secret abc123
python cli.py themes
"""
import argparse
import sys
import webbrowser
from pathlib import Path
import yaml
from converter import WeChatConverter, preview_html
from theme import load_theme, list_themes
from wechat_api import get_access_token, upload_image, upload_thumb
from publisher import create_draft, create_image_post
# Config file search order
CONFIG_PATHS = [
Path.cwd() / "config.yaml",
Path(__file__).parent.parent / "config.yaml", # skill root
Path(__file__).parent / "config.yaml", # toolkit dir
Path.home() / ".config" / "wewrite" / "config.yaml",
]
def load_config() -> dict:
"""Load config from first found config.yaml."""
for p in CONFIG_PATHS:
if p.exists():
with open(p, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
return {}
def cmd_preview(args):
"""Generate HTML preview and open in browser."""
theme = load_theme(args.theme)
converter = WeChatConverter(theme=theme)
result = converter.convert_file(args.input)
# Wrap in full HTML for browser preview
full_html = preview_html(result.html, theme)
# Write to temp file
input_path = Path(args.input)
output = args.output or str(input_path.with_suffix(".html"))
Path(output).write_text(full_html, encoding="utf-8")
print(f"Title: {result.title}")
print(f"Digest: {result.digest}")
print(f"Images: {len(result.images)}")
print(f"Output: {output}")
if not args.no_open:
webbrowser.open(f"file://{Path(output).absolute()}")
print("Opened in browser.")
def cmd_publish(args):
"""Convert, upload images, and create WeChat draft."""
cfg = load_config()
wechat_cfg = cfg.get("wechat", {})
# Resolve from CLI args → config.yaml fallback
appid = args.appid or wechat_cfg.get("appid")
secret = args.secret or wechat_cfg.get("secret")
theme_name = args.theme or cfg.get("theme", "professional-clean")
author = args.author or wechat_cfg.get("author")
if not appid or not secret:
print("Error: --appid and --secret required (or set in config.yaml)", file=sys.stderr)
sys.exit(1)
theme = load_theme(theme_name)
converter = WeChatConverter(theme=theme)
result = converter.convert_file(args.input)
print(f"Title: {result.title}")
print(f"Digest: {result.digest}")
print(f"Images found: {len(result.images)}")
# Get access token
token = get_access_token(appid, secret)
print("Access token obtained.")
# Upload images referenced in article and replace src
# Resolve relative paths against the markdown file's directory
md_dir = Path(args.input).resolve().parent
html = result.html
for img_src in result.images:
if img_src.startswith(("http://", "https://")):
print(f"Skipping remote image: {img_src}")
continue
# Try: absolute → relative to CWD → relative to markdown file
img_path = Path(img_src)
if not img_path.is_absolute():
if not img_path.exists():
img_path = md_dir / img_src
if img_path.exists():
print(f"Uploading image: {img_src}")
wechat_url = upload_image(token, str(img_path))
html = html.replace(img_src, wechat_url)
print(f" -> {wechat_url}")
else:
print(f"Warning: image not found: {img_src} (searched {md_dir})")
# Upload cover image if provided
thumb_media_id = None
if args.cover:
print(f"Uploading cover: {args.cover}")
thumb_media_id = upload_thumb(token, args.cover)
print(f" -> media_id: {thumb_media_id}")
# Create draft
title = args.title or result.title or Path(args.input).stem
digest = result.digest
draft = create_draft(
access_token=token,
title=title,
html=html,
digest=digest,
thumb_media_id=thumb_media_id,
author=author,
)
print(f"\nDraft created! media_id: {draft.media_id}")
def cmd_themes(args):
"""List available themes."""
names = list_themes()
for name in names:
theme = load_theme(name)
print(f" {name:24s} {theme.description}")
def cmd_image_post(args):
"""Create a WeChat image post (小绿书) from image files."""
cfg = load_config()
wechat_cfg = cfg.get("wechat", {})
appid = args.appid or wechat_cfg.get("appid")
secret = args.secret or wechat_cfg.get("secret")
if not appid or not secret:
print("Error: --appid and --secret required (or set in config.yaml)", file=sys.stderr)
sys.exit(1)
images = args.images
if not images:
print("Error: at least 1 image required", file=sys.stderr)
sys.exit(1)
if len(images) > 20:
print(f"Error: max 20 images, got {len(images)}", file=sys.stderr)
sys.exit(1)
token = get_access_token(appid, secret)
print(f"Uploading {len(images)} images as permanent materials...")
media_ids = []
for img_path in images:
p = Path(img_path)
if not p.exists():
print(f"Error: image not found: {img_path}", file=sys.stderr)
sys.exit(1)
print(f" Uploading: {p.name}")
mid = upload_thumb(token, str(p))
media_ids.append(mid)
print(f" -> {mid}")
title = args.title
if len(title) > 32:
print(f"Warning: title truncated to 32 chars (was {len(title)})")
title = title[:32]
content = args.content or ""
result = create_image_post(
access_token=token,
title=title,
image_media_ids=media_ids,
content=content,
open_comment=True,
)
print(f"\nImage post draft created!")
print(f" media_id: {result.media_id}")
print(f" images: {result.image_count}")
print(f" title: {title}")
print(f" 请到公众号后台草稿箱检查并发布")
def cmd_gallery(args):
"""Render all themes side by side in a browser gallery."""
from concurrent.futures import ThreadPoolExecutor
# Use provided markdown or a built-in sample
if args.input:
md_text = Path(args.input).read_text(encoding="utf-8")
else:
md_text = _gallery_sample_markdown()
names = list_themes()
results = {}
def render_theme(name):
theme = load_theme(name)
converter = WeChatConverter(theme=theme)
result = converter.convert(md_text)
return name, theme.description, result.html
# Parallel rendering
with ThreadPoolExecutor(max_workers=8) as pool:
for name, desc, html in pool.map(lambda n: render_theme(n), names):
results[name] = (desc, html)
# Build gallery HTML
gallery_html = _build_gallery_html(results, names)
output = args.output or "/tmp/wewrite-gallery.html"
Path(output).write_text(gallery_html, encoding="utf-8")
print(f"Gallery: {output} ({len(names)} themes)")
if not args.no_open:
webbrowser.open(f"file://{Path(output).absolute()}")
def _gallery_sample_markdown():
return """# 示例文章标题
## 第一部分
这是一段正常的文章内容用来展示不同主题的排版效果WeWrite 支持多种排版主题每种都有独特的视觉风格
说实话选主题这件事看截图永远不如看实际渲染效果
## 关键数据
| 指标 | 数值 | 变化 |
|------|------|------|
| 阅读量 | 12,580 | +23% |
| 分享率 | 4.7% | +0.8% |
| 完读率 | 68% | -2% |
## 代码示例
```python
def hello():
print("Hello, WeWrite!")
```
> 好的排版不是让读者注意到设计而是让读者忘记设计只记住内容
## 列表展示
- 第一个要点简洁是设计的灵魂
- 第二个要点一致性比创意更重要
- 第三个要点移动端体验优先
**加粗文本***斜体文本*的样式也需要关注
最后这段用来展示文章结尾的留白和间距效果一篇好文章的结尾应该像一首好歌的最后一个音符恰到好处地收束
"""
def _join_newline(items):
"""Join items with comma + newline (workaround for f-string limitation)."""
return ",\n".join(items)
def _build_gallery_html(results, names):
cards = []
for name in names:
desc, html = results[name]
# Escape for embedding in JS
escaped_html = html.replace('\\', '\\\\').replace('`', '\\`').replace('$', '\\$')
cards.append(f"""
<div class="theme-card" onclick="selectTheme('{name}')">
<div class="theme-name">{name}</div>
<div class="theme-desc">{desc}</div>
<div class="phone-frame">
<div class="phone-content" id="preview-{name}">{html}</div>
</div>
<button class="copy-btn" onclick="event.stopPropagation(); copyHTML('{name}')">复制 HTML</button>
</div>""")
# Store HTML data for copy
data_entries = []
for name in names:
desc, html = results[name]
safe = html.replace('\\', '\\\\').replace("'", "\\'").replace('\n', '\\n')
data_entries.append(f" '{name}': '{safe}'")
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WeWrite 主题画廊</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0f0f0f; color: #fff; }}
.header {{ text-align: center; padding: 40px 20px 20px; }}
.header h1 {{ font-size: 28px; font-weight: 700; }}
.header p {{ color: #888; margin-top: 8px; font-size: 15px; }}
.grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 24px; padding: 24px; max-width: 1440px; margin: 0 auto; }}
.theme-card {{ background: #1a1a1a; border-radius: 12px; padding: 16px; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; }}
.theme-card:hover {{ transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0,0,0,0.4); }}
.theme-name {{ font-size: 16px; font-weight: 700; margin-bottom: 4px; }}
.theme-desc {{ font-size: 13px; color: #888; margin-bottom: 12px; }}
.phone-frame {{ background: #fff; border-radius: 8px; overflow: hidden; max-height: 480px; overflow-y: auto; }}
.phone-content {{ padding: 16px; font-size: 14px; transform: scale(0.85); transform-origin: top left; width: 118%; }}
.copy-btn {{ margin-top: 12px; width: 100%; padding: 8px; background: #333; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }}
.copy-btn:hover {{ background: #555; }}
.toast {{ position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%); background: #333; color: #fff; padding: 10px 24px; border-radius: 8px; font-size: 14px; display: none; z-index: 999; }}
</style>
</head>
<body>
<div class="header">
<h1>WeWrite 主题画廊</h1>
<p>{len(names)} 个主题 · 点击卡片查看大图 · 点击复制 HTML直接粘贴到公众号编辑器</p>
</div>
<div class="grid">
{''.join(cards)}
</div>
<div class="toast" id="toast">已复制到剪贴板</div>
<script>
const themeData = {{
{_join_newline(data_entries)}
}};
function copyHTML(name) {{
const html = themeData[name];
if (html) {{
navigator.clipboard.writeText(html).then(() => {{
const t = document.getElementById('toast');
t.style.display = 'block';
setTimeout(() => t.style.display = 'none', 1500);
}});
}}
}}
function selectTheme(name) {{
localStorage.setItem('wewrite-theme', name);
// Scroll to card for visual feedback
const el = document.getElementById('preview-' + name);
if (el) el.scrollIntoView({{ behavior: 'smooth', block: 'center' }});
}}
</script>
</body>
</html>"""
def main():
parser = argparse.ArgumentParser(
prog="wewrite",
description="Markdown to WeChat HTML converter and publisher",
)
sub = parser.add_subparsers(dest="command", required=True)
# preview
p_preview = sub.add_parser("preview", help="Generate HTML and open in browser")
p_preview.add_argument("input", help="Markdown file path")
p_preview.add_argument("-t", "--theme", default="professional-clean", help="Theme name")
p_preview.add_argument("-o", "--output", help="Output HTML file path")
p_preview.add_argument("--no-open", action="store_true", help="Don't open browser")
# publish
p_publish = sub.add_parser("publish", help="Convert and publish as WeChat draft")
p_publish.add_argument("input", help="Markdown file path")
p_publish.add_argument("-t", "--theme", default=None, help="Theme name")
p_publish.add_argument("--appid", default=None, help="WeChat AppID (or set in config.yaml)")
p_publish.add_argument("--secret", default=None, help="WeChat AppSecret (or set in config.yaml)")
p_publish.add_argument("--cover", help="Cover image file path")
p_publish.add_argument("--title", help="Override article title")
p_publish.add_argument("--author", default=None, help="Article author")
# themes
sub.add_parser("themes", help="List available themes")
# image-post (小绿书)
p_imgpost = sub.add_parser("image-post", help="Create WeChat image post (小绿书)")
p_imgpost.add_argument("images", nargs="+", help="Image file paths (1-20, first = cover)")
p_imgpost.add_argument("-t", "--title", required=True, help="Post title (max 32 chars)")
p_imgpost.add_argument("-c", "--content", default="", help="Plain text description (max ~1000 chars)")
p_imgpost.add_argument("--appid", default=None, help="WeChat AppID")
p_imgpost.add_argument("--secret", default=None, help="WeChat AppSecret")
# gallery
p_gallery = sub.add_parser("gallery", help="Open theme gallery in browser")
p_gallery.add_argument("input", nargs="?", default=None, help="Markdown file (optional, uses sample if omitted)")
p_gallery.add_argument("-o", "--output", help="Output HTML file path")
p_gallery.add_argument("--no-open", action="store_true", help="Don't open browser")
args = parser.parse_args()
try:
if args.command == "preview":
cmd_preview(args)
elif args.command == "publish":
cmd_publish(args)
elif args.command == "themes":
cmd_themes(args)
elif args.command == "image-post":
cmd_image_post(args)
elif args.command == "gallery":
cmd_gallery(args)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

548
dist/openclaw/toolkit/converter.py vendored Normal file
View file

@ -0,0 +1,548 @@
"""
Markdown to WeChat-compatible HTML converter.
Forked from wechat_article_skills/scripts/markdown_to_html.py,
adapted for YAML-driven themes and agent integration.
"""
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import markdown
from bs4 import BeautifulSoup
from theme import Theme, load_theme, get_inline_css_rules
@dataclass
class ConvertResult:
"""Result of a Markdown → WeChat HTML conversion."""
html: str # WeChat-compatible inline-style HTML (body content only)
title: str # Extracted H1 title
digest: str # Auto-generated summary (first 120 chars)
images: list[str] = field(default_factory=list) # Image references found
class WeChatConverter:
"""Convert Markdown to WeChat-compatible inline-style HTML."""
def __init__(self, theme: Optional[Theme] = None, theme_name: str = "professional-clean"):
if theme is not None:
self._theme = theme
else:
self._theme = load_theme(theme_name)
self._css_rules = get_inline_css_rules(self._theme)
def convert(self, markdown_text: str) -> ConvertResult:
"""
Convert Markdown text to WeChat-compatible HTML.
Returns ConvertResult with:
- html: inline-style HTML (body content only, no <html>/<head> wrapper)
- title: extracted H1 title (or empty string)
- digest: first 120 characters of plain text
- images: list of image src references
"""
title = self._extract_title(markdown_text)
markdown_text = self._strip_h1(markdown_text)
# Pre-process container blocks (:::dialogue, :::timeline, etc.)
markdown_text = self._preprocess_containers(markdown_text)
# CJK fix: auto-space between CJK and Latin characters
markdown_text = self._fix_cjk_spacing(markdown_text)
# Parse Markdown → HTML
html = self._markdown_to_html(markdown_text)
# Enhance code blocks (add data-lang attribute)
html = self._enhance_code_blocks(html)
# Process images (ensure responsive styling)
html, images = self._process_images(html)
# CJK fix: move punctuation outside bold tags
html = self._fix_cjk_bold_punctuation(html)
# CJK fix: convert ul/ol to section-based lists (WeChat renders native lists unreliably)
html = self._convert_lists_to_sections(html)
# Convert external links to footnotes (WeChat blocks external links)
html = self._convert_links_to_footnotes(html)
# Apply inline CSS from theme
html = self._apply_inline_styles(html)
# Apply WeChat compatibility fixes
html = self._apply_wechat_fixes(html)
# Inject dark mode attributes
html = self._inject_darkmode(html)
# Generate digest from plain text
digest = self._generate_digest(html)
return ConvertResult(html=html, title=title, digest=digest, images=images)
def convert_file(self, input_path: str) -> ConvertResult:
"""Convert a Markdown file."""
path = Path(input_path)
if not path.exists():
raise FileNotFoundError(f"Input file not found: {input_path}")
text = path.read_text(encoding="utf-8")
return self.convert(text)
# -- internal methods --
def _extract_title(self, text: str) -> str:
"""Extract the first H1 title from Markdown text."""
for line in text.split("\n"):
stripped = line.strip()
if stripped.startswith("# ") and not stripped.startswith("## "):
return stripped[2:].strip()
return ""
def _strip_h1(self, text: str) -> str:
"""Remove H1 lines — WeChat has a separate title field."""
lines = []
for line in text.split("\n"):
stripped = line.strip()
if stripped.startswith("# ") and not stripped.startswith("## "):
continue
lines.append(line)
return "\n".join(lines)
def _markdown_to_html(self, text: str) -> str:
"""Parse Markdown to HTML using python-markdown with extensions."""
extensions = [
"markdown.extensions.fenced_code",
"markdown.extensions.tables",
"markdown.extensions.nl2br",
"markdown.extensions.sane_lists",
"markdown.extensions.codehilite",
]
extension_configs = {
"codehilite": {
"linenums": False,
"guess_lang": True,
"noclasses": True, # Inline syntax highlight styles
}
}
md = markdown.Markdown(extensions=extensions, extension_configs=extension_configs)
return md.convert(text)
def _enhance_code_blocks(self, html: str) -> str:
"""Add data-lang attribute to <pre> elements for language labeling."""
soup = BeautifulSoup(html, "html.parser")
for pre in soup.find_all("pre"):
code = pre.find("code")
if code:
for cls in code.get("class", []):
if cls.startswith("language-"):
pre["data-lang"] = cls.replace("language-", "")
break
return str(soup)
def _process_images(self, html: str) -> tuple[str, list[str]]:
"""Extract image references and ensure responsive styling."""
soup = BeautifulSoup(html, "html.parser")
images = []
for img in soup.find_all("img"):
src = img.get("src", "")
if src:
images.append(src)
# Ensure responsive image styles
existing = img.get("style", "")
if "max-width" not in existing:
additions = "max-width: 100%; height: auto; display: block; margin: 24px auto"
img["style"] = f"{existing}; {additions}" if existing else additions
return str(soup), images
def _apply_inline_styles(self, html: str) -> str:
"""Apply theme CSS rules as inline styles on matching elements."""
soup = BeautifulSoup(html, "html.parser")
for selector, styles in self._css_rules.items():
# Skip body — we don't wrap in body tag
if selector.strip() == "body":
continue
try:
elements = soup.select(selector)
except Exception:
continue
for elem in elements:
existing = elem.get("style", "")
style_dict = {}
# Parse existing inline styles
if existing:
for item in existing.split(";"):
if ":" in item:
key, val = item.split(":", 1)
style_dict[key.strip()] = val.strip()
# Add theme styles (existing styles take precedence)
for prop, val in styles.items():
if prop not in style_dict:
style_dict[prop] = val
elem["style"] = "; ".join(f"{k}: {v}" for k, v in style_dict.items())
return str(soup)
def _apply_wechat_fixes(self, html: str) -> str:
"""
Apply WeChat-specific compatibility fixes:
1. Force explicit color on every <p> tag
2. Ensure code blocks preserve whitespace
"""
soup = BeautifulSoup(html, "html.parser")
text_color = self._theme.colors.get("text", "#333333")
# Fix 1: Ensure all <p> tags have explicit color
for p in soup.find_all("p"):
style = p.get("style", "")
if "color" not in style:
p["style"] = f"{style}; color: {text_color}" if style else f"color: {text_color}"
# Fix 2: Ensure <pre> has whitespace preservation
for pre in soup.find_all("pre"):
style = pre.get("style", "")
if "white-space" not in style:
pre["style"] = f"{style}; white-space: pre-wrap; word-wrap: break-word" if style else "white-space: pre-wrap; word-wrap: break-word"
return str(soup)
# -- CJK compatibility fixes --
def _fix_cjk_spacing(self, text: str) -> str:
"""Auto-insert thin space between CJK and Latin/digit characters.
WeChat renders CJK-Latin without spacing, making mixed text hard to read.
This inserts a thin space (U+200A) at CJKLatin boundaries.
Runs on raw Markdown before parsing, skipping code blocks and links.
"""
# CJK unicode ranges
cjk = r'[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef]'
latin = r'[A-Za-z0-9]'
lines = text.split('\n')
result = []
in_code_block = False
for line in lines:
if line.strip().startswith('```'):
in_code_block = not in_code_block
result.append(line)
continue
if in_code_block:
result.append(line)
continue
# CJK followed by Latin
line = re.sub(f'({cjk})({latin})', r'\1 \2', line)
# Latin followed by CJK
line = re.sub(f'({latin})({cjk})', r'\1 \2', line)
result.append(line)
return '\n'.join(result)
def _fix_cjk_bold_punctuation(self, html: str) -> str:
"""Move Chinese punctuation outside bold/strong tags.
WeChat renders bold CJK punctuation with ugly spacing.
Move trailing punctuation () outside </strong>.
"""
# Match: <strong>内容+中文标点</strong> → <strong>内容</strong>标点
pattern = r'(<strong>)(.*?)([,。!?;:、]+)(</strong>)'
return re.sub(pattern, r'\1\2\4\3', html)
def _convert_lists_to_sections(self, html: str) -> str:
"""Convert <ul>/<ol> to styled <section> elements.
WeChat's native list rendering is unreliable (inconsistent bullet
style, broken indentation on some devices). Using section+span
for bullets/numbers gives full control over appearance.
"""
soup = BeautifulSoup(html, "html.parser")
text_color = self._theme.colors.get("text", "#333333")
primary = self._theme.colors.get("primary", "#2563eb")
for ul in soup.find_all("ul"):
section = soup.new_tag("section")
for li in ul.find_all("li", recursive=False):
item = soup.new_tag("section", style=f"display: flex; align-items: flex-start; margin-bottom: 8px; color: {text_color}")
bullet = soup.new_tag("span", style=f"color: {primary}; margin-right: 8px; flex-shrink: 0; font-size: 18px; line-height: 1.6")
bullet.string = ""
content = soup.new_tag("span", style="flex: 1")
for child in list(li.children):
content.append(child.extract() if hasattr(child, 'extract') else child)
item.append(bullet)
item.append(content)
section.append(item)
ul.replace_with(section)
for idx, ol in enumerate(soup.find_all("ol")):
section = soup.new_tag("section")
for num, li in enumerate(ol.find_all("li", recursive=False), 1):
item = soup.new_tag("section", style=f"display: flex; align-items: flex-start; margin-bottom: 8px; color: {text_color}")
number = soup.new_tag("span", style=f"color: {primary}; margin-right: 8px; flex-shrink: 0; font-weight: 700; line-height: 1.8")
number.string = f"{num}."
content = soup.new_tag("span", style="flex: 1")
for child in list(li.children):
content.append(child.extract() if hasattr(child, 'extract') else child)
item.append(number)
item.append(content)
section.append(item)
ol.replace_with(section)
return str(soup)
# -- External link → footnote conversion --
def _convert_links_to_footnotes(self, html: str) -> str:
"""Convert external <a> links to superscript footnote numbers.
WeChat blocks external links readers see dead text. This converts
each external link to a superscript number with the URL collected
into a reference list appended at the end.
"""
soup = BeautifulSoup(html, "html.parser")
footnotes = []
counter = 0
primary = self._theme.colors.get("primary", "#2563eb")
for a in soup.find_all("a"):
href = a.get("href", "")
if not href or href.startswith("#"):
continue # skip anchors
counter += 1
text = a.get_text()
footnotes.append((counter, text, href))
# Replace <a> with text + superscript number
sup = soup.new_tag("sup")
sup_link = soup.new_tag("span", style=f"color: {primary}; font-size: 12px")
sup_link.string = f"[{counter}]"
sup.append(sup_link)
a.replace_with(text, sup)
if footnotes:
# Append reference section
hr = soup.new_tag("hr", style="border: none; border-top: 1px solid #e5e5e5; margin: 32px 0 16px")
soup.append(hr)
ref_title = soup.new_tag("p", style="font-size: 13px; color: #999999; margin-bottom: 8px; font-weight: 700")
ref_title.string = "参考链接"
soup.append(ref_title)
for num, text, href in footnotes:
ref = soup.new_tag("p", style="font-size: 12px; color: #999999; margin: 2px 0; word-break: break-all")
ref.string = f"[{num}] {text}: {href}"
soup.append(ref)
return str(soup)
# -- Dark mode --
def _inject_darkmode(self, html: str) -> str:
"""Inject data-darkmode-* attributes for WeChat dark mode.
WeChat auto-inverts colors in dark mode, which often breaks
designed color schemes. Explicit darkmode attributes tell WeChat
exactly what colors to use instead of guessing.
"""
darkmode = self._theme.colors.get("darkmode", {})
if not darkmode:
return html
soup = BeautifulSoup(html, "html.parser")
dm_text = darkmode.get("text", "#c8c8c8")
dm_bg = darkmode.get("background", "#1e1e1e")
dm_primary = darkmode.get("primary", "#6aadff")
# Body-level elements (p, li, section, span)
for tag_name in ("p", "span", "section"):
for elem in soup.find_all(tag_name):
style = elem.get("style", "")
# Only set if element has a color
if "color" in style:
elem["data-darkmode-color"] = dm_text
elem["data-darkmode-bgcolor"] = "transparent"
# Headings
dm_heading = darkmode.get("text", "#e0e0e0")
for tag_name in ("h1", "h2", "h3", "h4"):
for elem in soup.find_all(tag_name):
elem["data-darkmode-color"] = dm_heading
elem["data-darkmode-bgcolor"] = "transparent"
# Code blocks
dm_code_bg = darkmode.get("code_bg", "#2d2d2d")
dm_code_color = darkmode.get("code_color", "#d4d4d4")
for pre in soup.find_all("pre"):
pre["data-darkmode-bgcolor"] = dm_code_bg
pre["data-darkmode-color"] = dm_code_color
for code in soup.find_all("code"):
code["data-darkmode-color"] = dm_code_color
# Blockquotes
dm_quote_bg = darkmode.get("quote_bg", "#2a2a2a")
for bq in soup.find_all("blockquote"):
bq["data-darkmode-bgcolor"] = dm_quote_bg
bq["data-darkmode-color"] = dm_text
# Strong/em with primary color
for strong in soup.find_all("strong"):
strong["data-darkmode-color"] = dm_primary
return str(soup)
# -- Container block syntax --
def _preprocess_containers(self, text: str) -> str:
"""Pre-process :::container blocks into styled HTML before Markdown parsing.
Supports:
:::dialogue chat bubble layout
:::timeline vertical timeline with dots
:::callout Obsidian-style callout (tip/warning/info/danger)
:::quote styled pull quote
"""
text = self._process_dialogue(text)
text = self._process_timeline(text)
text = self._process_callout(text)
text = self._process_quote_block(text)
return text
def _process_dialogue(self, text: str) -> str:
"""Convert :::dialogue blocks to chat bubble HTML."""
primary = self._theme.colors.get("primary", "#2563eb")
def replace_dialogue(match):
content = match.group(1).strip()
bubbles = []
for line in content.split('\n'):
line = line.strip()
if not line:
continue
if line.startswith('> '):
# Right-aligned (reply) bubble
msg = line[2:].strip()
bubbles.append(f'<section style="display: flex; justify-content: flex-end; margin-bottom: 12px">'
f'<section style="background: {primary}; color: white; padding: 10px 14px; border-radius: 12px 12px 2px 12px; max-width: 80%; font-size: 15px; line-height: 1.6">{msg}</section></section>')
else:
# Left-aligned bubble
bubbles.append(f'<section style="display: flex; justify-content: flex-start; margin-bottom: 12px">'
f'<section style="background: #f3f4f6; color: #333; padding: 10px 14px; border-radius: 12px 12px 12px 2px; max-width: 80%; font-size: 15px; line-height: 1.6">{line}</section></section>')
return '\n'.join(bubbles)
return re.sub(r':::dialogue\n(.*?)\n:::', replace_dialogue, text, flags=re.DOTALL)
def _process_timeline(self, text: str) -> str:
"""Convert :::timeline blocks to vertical timeline HTML."""
primary = self._theme.colors.get("primary", "#2563eb")
def replace_timeline(match):
content = match.group(1).strip()
items = []
for line in content.split('\n'):
line = line.strip()
if not line:
continue
# Format: "**title** description" or just "description"
items.append(
f'<section style="display: flex; margin-bottom: 16px">'
f'<section style="flex-shrink: 0; width: 12px; display: flex; flex-direction: column; align-items: center">'
f'<section style="width: 10px; height: 10px; border-radius: 50%; background: {primary}; margin-top: 6px"></section>'
f'<section style="width: 2px; flex: 1; background: #e5e7eb; margin-top: 4px"></section>'
f'</section>'
f'<section style="flex: 1; padding-left: 12px; padding-bottom: 8px; font-size: 15px; line-height: 1.7">{line}</section>'
f'</section>'
)
return '\n'.join(items)
return re.sub(r':::timeline\n(.*?)\n:::', replace_timeline, text, flags=re.DOTALL)
def _process_callout(self, text: str) -> str:
"""Convert :::callout blocks to styled callout boxes.
Syntax: :::callout tip/warning/info/danger
"""
colors_map = {
"tip": ("#059669", "#ecfdf5", "💡"),
"warning": ("#d97706", "#fffbeb", "⚠️"),
"info": ("#2563eb", "#eff6ff", ""),
"danger": ("#dc2626", "#fef2f2", "🚨"),
}
def replace_callout(match):
ctype = match.group(1).strip().lower()
content = match.group(2).strip()
color, bg, icon = colors_map.get(ctype, colors_map["info"])
return (f'<section style="background: {bg}; border-left: 4px solid {color}; '
f'padding: 14px 16px; border-radius: 4px; margin: 16px 0; font-size: 15px; line-height: 1.7">'
f'<section style="font-weight: 700; color: {color}; margin-bottom: 6px">{icon} {ctype.upper()}</section>'
f'{content}</section>')
return re.sub(r':::callout\s+(\w+)\n(.*?)\n:::', replace_callout, text, flags=re.DOTALL)
def _process_quote_block(self, text: str) -> str:
"""Convert :::quote blocks to styled pull quotes."""
primary = self._theme.colors.get("primary", "#2563eb")
def replace_quote(match):
content = match.group(1).strip()
return (f'<section style="margin: 24px 0; padding: 20px 24px; border-left: 4px solid {primary}; '
f'background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%); border-radius: 0 8px 8px 0">'
f'<section style="font-size: 18px; line-height: 1.8; color: #333; font-style: italic">'
f'"{content}"</section></section>')
return re.sub(r':::quote\n(.*?)\n:::', replace_quote, text, flags=re.DOTALL)
# -- Digest generation --
def _generate_digest(self, html: str, max_bytes: int = 120) -> str:
"""Generate a digest that fits within WeChat's byte limit (120 bytes UTF-8)."""
soup = BeautifulSoup(html, "html.parser")
text = soup.get_text(separator=" ", strip=True)
text = re.sub(r"\s+", " ", text).strip()
# Truncate to fit within max_bytes (UTF-8)
ellipsis = "..."
ellipsis_bytes = len(ellipsis.encode("utf-8"))
target_bytes = max_bytes - ellipsis_bytes
encoded = text.encode("utf-8")
if len(encoded) <= max_bytes:
return text
# Truncate at valid UTF-8 boundary
truncated = encoded[:target_bytes].decode("utf-8", errors="ignore").rstrip()
return truncated + ellipsis
def preview_html(body_html: str, theme: Theme) -> str:
"""
Wrap body content in a full HTML document for browser preview.
This is only for local preview NOT for WeChat publishing.
"""
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Preview</title>
<style>
{theme.base_css}
</style>
</head>
<body>
{body_html}
</body>
</html>"""

318
dist/openclaw/toolkit/image_gen.py vendored Normal file
View file

@ -0,0 +1,318 @@
#!/usr/bin/env python3
"""
AI image generation module for WeWrite.
Supports multiple providers via a simple abstraction:
- doubao-seedream (Volcengine Ark) default, good for Chinese prompts
- openai (DALL-E 3) broad availability
- Custom providers via ImageProvider base class
Usage as CLI:
python3 image_gen.py --prompt "描述" --output cover.png
python3 image_gen.py --prompt "描述" --output cover.png --size cover
python3 image_gen.py --prompt "描述" --output cover.png --provider openai
Usage as module:
from image_gen import generate_image
path = generate_image("prompt text", "output.png", size="cover")
"""
import abc
import argparse
import json
import sys
from pathlib import Path
import requests
import yaml
# --- Config ---
CONFIG_PATHS = [
Path.cwd() / "config.yaml",
Path(__file__).parent.parent / "config.yaml", # skill root
Path(__file__).parent / "config.yaml", # toolkit dir
Path.home() / ".config" / "wewrite" / "config.yaml",
]
def _load_config() -> dict:
for p in CONFIG_PATHS:
if p.exists():
with open(p, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
return {}
# --- Size presets ---
# Cover: 2.35:1 微信封面比例
# Article: 16:9 横版内文配图
# Vertical: 9:16 竖版
SIZE_PRESETS = {
"cover": {"doubao": "2952x1256", "openai": "1792x1024"},
"article": {"doubao": "2560x1440", "openai": "1792x1024"},
"vertical": {"doubao": "1088x2560", "openai": "1024x1792"},
"square": {"doubao": "2048x2048", "openai": "1024x1024"},
}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
def _compress_image(raw_bytes: bytes, max_size: int) -> bytes:
"""Compress image to fit under max_size by reducing JPEG quality."""
from io import BytesIO
from PIL import Image
img = Image.open(BytesIO(raw_bytes))
if img.mode == "RGBA":
img = img.convert("RGB")
for quality in (90, 80, 70, 60, 50):
buf = BytesIO()
img.save(buf, format="JPEG", quality=quality, optimize=True)
if buf.tell() <= max_size:
return buf.getvalue()
return buf.getvalue()
# --- Provider abstraction ---
class ImageProvider(abc.ABC):
"""Base class for image generation providers."""
@abc.abstractmethod
def generate(self, prompt: str, size: str) -> bytes:
"""Generate an image and return raw bytes.
Args:
prompt: Image description (Chinese or English).
size: Resolved size string (e.g. "1792x1024").
Returns:
Raw image bytes.
"""
...
def resolve_size(self, preset: str) -> str:
"""Resolve a size preset to a concrete size string for this provider."""
provider_key = self.provider_key
if preset in SIZE_PRESETS:
return SIZE_PRESETS[preset].get(provider_key, list(SIZE_PRESETS[preset].values())[0])
return preset # assume explicit WxH
@property
@abc.abstractmethod
def provider_key(self) -> str:
"""Short identifier used for size preset lookup."""
...
class DoubaoProvider(ImageProvider):
"""doubao-seedream via Volcengine Ark API."""
provider_key = "doubao"
def __init__(self, api_key: str, model: str = "doubao-seedream-5-0-260128",
base_url: str = "https://ark.cn-beijing.volces.com/api/v3"):
self._api_key = api_key
self._model = model
self._base_url = base_url
def generate(self, prompt: str, size: str) -> bytes:
body = {
"model": self._model,
"prompt": prompt,
"response_format": "url",
"size": size,
"stream": False,
"watermark": False,
}
resp = requests.post(
f"{self._base_url}/images/generations",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self._api_key}",
},
json=body,
timeout=120,
)
data = resp.json()
if resp.status_code != 200:
error = data.get("error", {})
msg = error.get("message", json.dumps(data, ensure_ascii=False))
raise ValueError(f"Doubao API error ({resp.status_code}): {msg}")
image_data = data.get("data", [])
if not image_data:
raise ValueError(f"No image returned: {json.dumps(data, ensure_ascii=False)}")
image_url = image_data[0].get("url")
if not image_url:
raise ValueError(f"No image URL in response: {json.dumps(data, ensure_ascii=False)}")
img_resp = requests.get(image_url, timeout=60)
img_resp.raise_for_status()
return img_resp.content
class OpenAIProvider(ImageProvider):
"""OpenAI DALL-E 3 provider."""
provider_key = "openai"
def __init__(self, api_key: str, model: str = "dall-e-3",
base_url: str = "https://api.openai.com/v1"):
self._api_key = api_key
self._model = model
self._base_url = base_url
def generate(self, prompt: str, size: str) -> bytes:
# DALL-E 3 expects size as "WxH" format
dall_e_size = size.replace("x", "x") # normalize
body = {
"model": self._model,
"prompt": prompt,
"n": 1,
"size": dall_e_size,
"response_format": "url",
}
resp = requests.post(
f"{self._base_url}/images/generations",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self._api_key}",
},
json=body,
timeout=120,
)
data = resp.json()
if resp.status_code != 200:
error = data.get("error", {})
msg = error.get("message", json.dumps(data, ensure_ascii=False))
raise ValueError(f"OpenAI API error ({resp.status_code}): {msg}")
image_data = data.get("data", [])
if not image_data:
raise ValueError(f"No image returned: {json.dumps(data, ensure_ascii=False)}")
image_url = image_data[0].get("url")
if not image_url:
raise ValueError(f"No image URL in response: {json.dumps(data, ensure_ascii=False)}")
img_resp = requests.get(image_url, timeout=60)
img_resp.raise_for_status()
return img_resp.content
# --- Provider registry ---
PROVIDERS = {
"doubao": DoubaoProvider,
"openai": OpenAIProvider,
}
def _build_provider(config: dict) -> ImageProvider:
"""Build an ImageProvider from config.yaml's image section."""
img_cfg = config.get("image", {})
provider_name = img_cfg.get("provider", "doubao")
api_key = img_cfg.get("api_key")
if not api_key:
raise ValueError(
f"image.api_key not set in config.yaml. "
f"Configure your {provider_name} API key to enable image generation."
)
provider_cls = PROVIDERS.get(provider_name)
if not provider_cls:
raise ValueError(
f"Unknown image provider: '{provider_name}'. "
f"Available: {', '.join(PROVIDERS.keys())}"
)
kwargs = {"api_key": api_key}
if img_cfg.get("model"):
kwargs["model"] = img_cfg["model"]
if img_cfg.get("base_url"):
kwargs["base_url"] = img_cfg["base_url"]
return provider_cls(**kwargs)
# --- Public API ---
def generate_image(
prompt: str,
output_path: str,
size: str = "cover",
config: dict = None,
) -> str:
"""
Generate an image using the configured provider.
Args:
prompt: Image generation prompt (Chinese or English).
output_path: Where to save the image.
size: Size preset ("cover", "article", "vertical", "square") or explicit "WxH".
config: Optional config dict. If None, loads from config.yaml.
Returns:
The output file path.
"""
if config is None:
config = _load_config()
provider = _build_provider(config)
resolved_size = provider.resolve_size(size)
raw_bytes = provider.generate(prompt, resolved_size)
# Compress if over 5MB (WeChat upload limit)
if len(raw_bytes) > MAX_FILE_SIZE:
raw_bytes = _compress_image(raw_bytes, MAX_FILE_SIZE)
output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_bytes(raw_bytes)
return str(output)
def main():
parser = argparse.ArgumentParser(
description="Generate images using AI (doubao-seedream, OpenAI DALL-E, etc.)"
)
parser.add_argument("--prompt", required=True, help="Image generation prompt")
parser.add_argument("--output", required=True, help="Output file path")
parser.add_argument(
"--size",
default="cover",
help="Size: cover, article, vertical, square, or WxH",
)
parser.add_argument(
"--provider",
default=None,
help="Override provider (doubao, openai). Default: from config.yaml",
)
args = parser.parse_args()
try:
config = _load_config()
if args.provider:
config.setdefault("image", {})["provider"] = args.provider
path = generate_image(args.prompt, args.output, size=args.size, config=config)
print(f"Image saved: {path}")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

138
dist/openclaw/toolkit/publisher.py vendored Normal file
View file

@ -0,0 +1,138 @@
import json
import requests
from dataclasses import dataclass
from typing import Optional
@dataclass
class DraftResult:
media_id: str
@dataclass
class ImagePostResult:
media_id: str
image_count: int
def create_draft(
access_token: str,
title: str,
html: str,
digest: str,
thumb_media_id: Optional[str] = None,
author: Optional[str] = None,
) -> DraftResult:
"""
Create a draft in WeChat.
API: POST https://api.weixin.qq.com/cgi-bin/draft/add
Returns DraftResult.
Raise ValueError on error (errcode present and != 0).
"""
article = {
"title": title,
"author": author or "",
"digest": digest,
"content": html,
"show_cover_pic": 0,
}
# thumb_media_id is required by WeChat API — if not provided,
# upload a default 1x1 white pixel, or skip if truly empty
if thumb_media_id:
article["thumb_media_id"] = thumb_media_id
body = {"articles": [article]}
# MUST use ensure_ascii=False — otherwise Chinese becomes \uXXXX
# and WeChat stores the escape sequences literally, causing title
# length overflow and garbled content.
resp = requests.post(
"https://api.weixin.qq.com/cgi-bin/draft/add",
params={"access_token": access_token},
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
headers={"Content-Type": "application/json; charset=utf-8"},
)
data = resp.json()
errcode = data.get("errcode", 0)
if errcode != 0:
errmsg = data.get("errmsg", "unknown error")
raise ValueError(f"WeChat create_draft error: errcode={errcode}, errmsg={errmsg}")
if "media_id" not in data:
raise ValueError(f"WeChat create_draft error: missing media_id in response: {data}")
return DraftResult(media_id=data["media_id"])
def create_image_post(
access_token: str,
title: str,
image_media_ids: list[str],
content: str = "",
open_comment: bool = False,
fans_only_comment: bool = False,
) -> ImagePostResult:
"""
Create a WeChat image post (小绿书/图片帖) draft.
This uses article_type="newspic" which displays as a horizontal
swipe carousel (3:4 ratio), similar to Xiaohongshu.
Args:
access_token: WeChat access token.
title: Post title, max 32 characters.
image_media_ids: List of permanent media_ids from upload_thumb().
Min 1, max 20. First image becomes the cover.
content: Plain text description, max ~1000 chars. No HTML.
open_comment: Allow comments.
fans_only_comment: Only followers can comment.
Returns ImagePostResult with media_id of created draft.
"""
if not image_media_ids:
raise ValueError("At least 1 image is required for image post")
if len(image_media_ids) > 20:
raise ValueError(f"Max 20 images allowed, got {len(image_media_ids)}")
if len(title) > 32:
raise ValueError(f"Title max 32 chars for image post, got {len(title)}")
article = {
"article_type": "newspic",
"title": title,
"content": content,
"image_info": {
"image_list": [
{"image_media_id": mid} for mid in image_media_ids
]
},
"need_open_comment": 1 if open_comment else 0,
"only_fans_can_comment": 1 if fans_only_comment else 0,
}
body = {"articles": [article]}
resp = requests.post(
"https://api.weixin.qq.com/cgi-bin/draft/add",
params={"access_token": access_token},
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
headers={"Content-Type": "application/json; charset=utf-8"},
)
data = resp.json()
errcode = data.get("errcode", 0)
if errcode != 0:
errmsg = data.get("errmsg", "unknown error")
raise ValueError(f"WeChat create_image_post error: errcode={errcode}, errmsg={errmsg}")
if "media_id" not in data:
raise ValueError(f"WeChat create_image_post: missing media_id in response: {data}")
return ImagePostResult(
media_id=data["media_id"],
image_count=len(image_media_ids),
)

197
dist/openclaw/toolkit/theme.py vendored Normal file
View file

@ -0,0 +1,197 @@
"""
Theme system for WeWrite.
Loads YAML theme definitions and provides CSS parsing utilities
for the inline style converter.
"""
import logging
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import cssutils
import yaml
# Suppress cssutils warnings (it's very noisy about non-standard properties)
cssutils.log.setLevel(logging.CRITICAL)
@dataclass
class Theme:
"""A theme definition with colors and base CSS."""
name: str
description: str
base_css: str
colors: dict = field(default_factory=dict)
def _default_themes_dir() -> str:
"""Return the themes/ directory relative to this file."""
return str(Path(__file__).parent / "themes")
def load_theme(name: str, themes_dir: str = None) -> Theme:
"""
Load a theme by name from a YAML file.
Args:
name: Theme name (without .yaml extension).
themes_dir: Directory containing theme YAML files.
Defaults to themes/ relative to this file.
Returns:
A Theme object.
Raises:
FileNotFoundError: If the theme YAML file does not exist.
ValueError: If the YAML is malformed or missing required fields.
"""
if themes_dir is None:
themes_dir = _default_themes_dir()
theme_path = os.path.join(themes_dir, f"{name}.yaml")
if not os.path.exists(theme_path):
raise FileNotFoundError(f"Theme file not found: {theme_path}")
with open(theme_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict):
raise ValueError(f"Invalid theme file: {theme_path}")
required = ("name", "description", "base_css", "colors")
for key in required:
if key not in data:
raise ValueError(f"Theme file missing required field '{key}': {theme_path}")
return Theme(
name=data["name"],
description=data["description"],
base_css=data["base_css"],
colors=data.get("colors", {}),
)
def list_themes(themes_dir: str = None) -> list[str]:
"""
List available theme names.
Args:
themes_dir: Directory containing theme YAML files.
Defaults to themes/ relative to this file.
Returns:
Sorted list of theme names (without .yaml extension).
"""
if themes_dir is None:
themes_dir = _default_themes_dir()
if not os.path.isdir(themes_dir):
return []
names = []
for filename in os.listdir(themes_dir):
if filename.endswith(".yaml") or filename.endswith(".yml"):
names.append(filename.rsplit(".", 1)[0])
return sorted(names)
def _resolve_css_variables(css_text: str, colors: dict) -> str:
"""
Replace var(--xxx) references in CSS with actual color values.
Supports var(--primary), var(--secondary), etc. based on the
colors dict keys. The CSS variable name is mapped by stripping
the leading --.
"""
def replacer(match: re.Match) -> str:
var_name = match.group(1).strip()
# Strip leading -- prefix
key = var_name.lstrip("-")
# Also try with hyphens converted to underscores
key_underscore = key.replace("-", "_")
if key in colors:
return str(colors[key])
if key_underscore in colors:
return str(colors[key_underscore])
# Return original if not found
return match.group(0)
return re.sub(r"var\(\s*--([a-zA-Z0-9_-]+)\s*\)", replacer, css_text)
def _is_simple_selector(selector: str) -> bool:
"""
Check if a selector is simple enough for inline styling.
Rejects pseudo-classes, pseudo-elements, media queries,
and complex combinators.
"""
selector = selector.strip()
# Reject if contains any of these characters
reject_chars = (":", "@", ">", "+", "~", "[", "*")
for ch in reject_chars:
if ch in selector:
return False
return True
def get_inline_css_rules(theme: Theme) -> dict[str, dict[str, str]]:
"""
Parse a theme's base_css into a selector -> {property: value} dict.
This resolves CSS variable references using theme.colors, then
parses the CSS with cssutils. Only simple selectors are included
(no pseudo-classes, pseudo-elements, media queries, or complex
combinators).
Args:
theme: A Theme object with base_css and colors.
Returns:
Dict mapping CSS selectors to dicts of {property: value}.
Example: {"h1": {"color": "#333", "font-size": "28px"}, ...}
"""
# Resolve CSS variables first
resolved_css = _resolve_css_variables(theme.base_css, theme.colors)
# Parse with cssutils
sheet = cssutils.parseString(resolved_css, validate=False)
rules: dict[str, dict[str, str]] = {}
for rule in sheet:
if rule.type != rule.STYLE_RULE:
continue
selector_text = rule.selectorText
# A rule can have multiple comma-separated selectors
selectors = [s.strip() for s in selector_text.split(",")]
# Build property dict for this rule
props: dict[str, str] = {}
for prop in rule.style:
props[prop.name] = prop.value
if not props:
continue
for selector in selectors:
if not _is_simple_selector(selector):
continue
if selector in rules:
# Merge (later rules override)
rules[selector].update(props)
else:
rules[selector] = dict(props)
return rules

View file

@ -0,0 +1,207 @@
name: "bauhaus"
description: "包豪斯设计风格:纯白底黑色为主,红蓝黄色块点缀,几何感强烈"
colors:
primary: "#e63226"
secondary: "#004592"
text: "#1a1a1a"
text_light: "#555555"
background: "#ffffff"
code_bg: "#f0f0f0"
code_color: "#004592"
quote_border: "#e63226"
quote_bg: "#fff5f5"
border_radius: "0px"
darkmode:
background: "#111111"
text: "#e8e8e8"
text_light: "#a0a0a0"
primary: "#f04438"
code_bg: "#222222"
code_color: "#5b9bd5"
quote_bg: "#1e1212"
quote_border: "#f04438"
base_css: |
body {
font-family: "Helvetica Neue", Helvetica, Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 16px;
line-height: 1.75;
color: #1a1a1a;
background: #ffffff;
max-width: 720px;
margin: 0 auto;
padding: 20px;
word-wrap: break-word;
}
h1 {
font-size: 30px;
font-weight: 900;
color: #1a1a1a;
margin: 36px 0 18px 0;
padding: 12px 16px;
background: #e63226;
color: #ffffff;
line-height: 1.3;
text-transform: uppercase;
letter-spacing: 0.04em;
}
h2 {
font-size: 22px;
font-weight: 800;
color: #004592;
margin: 32px 0 14px 0;
padding: 8px 0;
border-bottom: 4px solid #1a1a1a;
line-height: 1.4;
}
h3 {
font-size: 18px;
font-weight: 700;
color: #1a1a1a;
margin: 24px 0 12px 0;
padding-left: 12px;
border-left: 6px solid #f5b700;
line-height: 1.4;
}
h4 {
font-size: 16px;
font-weight: 700;
color: #1a1a1a;
margin: 20px 0 10px 0;
line-height: 1.4;
}
p {
font-size: 16px;
line-height: 1.75;
color: #1a1a1a;
margin: 12px 0;
}
strong {
font-weight: 800;
color: #e63226;
}
em {
font-style: italic;
color: #555555;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: #f0f0f0;
color: #004592;
padding: 2px 6px;
border-radius: 0px;
border: 1px solid #ddd;
}
pre {
background: #1a1a1a;
color: #f0f0f0;
padding: 16px;
border-radius: 0px;
overflow-x: auto;
margin: 16px 0;
line-height: 1.6;
border-left: 6px solid #e63226;
}
pre code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: none;
color: #f0f0f0;
padding: 0;
border-radius: 0;
border: none;
}
blockquote {
border-left: 6px solid #e63226;
background: #fff5f5;
margin: 16px 0;
padding: 12px 16px;
border-radius: 0;
color: #1a1a1a;
}
blockquote p {
margin: 8px 0;
color: #1a1a1a;
}
ul {
padding-left: 24px;
margin: 12px 0;
}
ol {
padding-left: 24px;
margin: 12px 0;
}
li {
font-size: 16px;
line-height: 1.75;
color: #1a1a1a;
margin: 6px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 15px;
}
thead {
background: #004592;
}
th {
background: #004592;
color: #ffffff;
font-weight: 700;
padding: 10px 14px;
text-align: left;
border: 2px solid #1a1a1a;
}
td {
padding: 10px 14px;
border: 2px solid #1a1a1a;
color: #1a1a1a;
}
tr {
background: #ffffff;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 24px auto;
border-radius: 0px;
border: 2px solid #1a1a1a;
}
a {
color: #004592;
text-decoration: none;
font-weight: 700;
border-bottom: 2px solid #f5b700;
}
hr {
border: none;
height: 4px;
background: #1a1a1a;
margin: 28px 0;
}

View file

@ -0,0 +1,198 @@
name: "bold-green"
description: "大胆森林绿风格:白底绿色主色,清新自然,适合环保健康和可持续发展内容"
colors:
primary: "#16a34a"
secondary: "#22c55e"
text: "#1a2e1a"
text_light: "#4a6a4a"
background: "#ffffff"
code_bg: "#f0fdf4"
code_color: "#15803d"
quote_border: "#16a34a"
quote_bg: "#f0fdf4"
border_radius: "8px"
darkmode:
background: "#0f1a0f"
text: "#d8e8d8"
text_light: "#8aaa8a"
primary: "#4ade80"
code_bg: "#162816"
code_color: "#6ee7a0"
quote_bg: "#142014"
quote_border: "#4ade80"
base_css: |
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 16px;
line-height: 1.8;
color: #1a2e1a;
background: #ffffff;
max-width: 720px;
margin: 0 auto;
padding: 20px;
word-wrap: break-word;
}
h1 {
font-size: 26px;
font-weight: 700;
color: #ffffff;
margin: 32px 0 16px 0;
padding: 12px 16px;
background: #16a34a;
border-radius: 8px;
line-height: 1.4;
}
h2 {
font-size: 22px;
font-weight: 700;
color: #16a34a;
margin: 28px 0 14px 0;
padding: 8px 0 8px 12px;
border-left: 4px solid #16a34a;
line-height: 1.4;
}
h3 {
font-size: 18px;
font-weight: 600;
color: #15803d;
margin: 24px 0 12px 0;
line-height: 1.4;
}
h4 {
font-size: 16px;
font-weight: 600;
color: #1a2e1a;
margin: 20px 0 10px 0;
line-height: 1.4;
}
p {
font-size: 16px;
line-height: 1.8;
color: #1a2e1a;
margin: 12px 0;
}
strong {
font-weight: 700;
color: #16a34a;
}
em {
font-style: italic;
color: #4a6a4a;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: #f0fdf4;
color: #15803d;
padding: 2px 6px;
border-radius: 4px;
}
pre {
background: #f0fdf4;
color: #1a2e1a;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
margin: 16px 0;
line-height: 1.6;
border: 1px solid #bbf7d0;
}
pre code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: none;
color: #1a2e1a;
padding: 0;
border-radius: 0;
}
blockquote {
border-left: 4px solid #16a34a;
background: #f0fdf4;
margin: 16px 0;
padding: 12px 16px;
border-radius: 0 8px 8px 0;
color: #1a2e1a;
}
blockquote p {
margin: 8px 0;
color: #1a2e1a;
}
ul {
padding-left: 24px;
margin: 12px 0;
}
ol {
padding-left: 24px;
margin: 12px 0;
}
li {
font-size: 16px;
line-height: 1.8;
color: #1a2e1a;
margin: 6px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 15px;
}
thead {
background: #16a34a;
}
th {
background: #16a34a;
color: #ffffff;
font-weight: 600;
padding: 10px 14px;
text-align: left;
border: 1px solid #16a34a;
}
td {
padding: 10px 14px;
border: 1px solid #d1fae5;
color: #1a2e1a;
}
tr {
background: #ffffff;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 24px auto;
border-radius: 8px;
}
a {
color: #16a34a;
text-decoration: none;
font-weight: 500;
}
hr {
border: none;
border-top: 2px solid #d1fae5;
margin: 24px 0;
}

View file

@ -0,0 +1,197 @@
name: "bold-navy"
description: "大胆藏青风格:白底藏青主色,稳重专业,适合金融商务和行业分析内容"
colors:
primary: "#1e3a5f"
secondary: "#2c5282"
text: "#1a1a2e"
text_light: "#4a5568"
background: "#ffffff"
code_bg: "#f0f4f8"
code_color: "#1e3a5f"
quote_border: "#1e3a5f"
quote_bg: "#f0f4f8"
border_radius: "6px"
darkmode:
background: "#0a0f1a"
text: "#d8dce8"
text_light: "#8890a0"
primary: "#5b8cc8"
code_bg: "#141a28"
code_color: "#7ca8e0"
quote_bg: "#101828"
quote_border: "#5b8cc8"
base_css: |
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 16px;
line-height: 1.8;
color: #1a1a2e;
background: #ffffff;
max-width: 720px;
margin: 0 auto;
padding: 20px;
word-wrap: break-word;
}
h1 {
font-size: 26px;
font-weight: 700;
color: #1e3a5f;
margin: 32px 0 16px 0;
padding-bottom: 12px;
border-bottom: 3px solid #1e3a5f;
line-height: 1.4;
}
h2 {
font-size: 22px;
font-weight: 700;
color: #1e3a5f;
margin: 28px 0 14px 0;
padding: 8px 0 8px 12px;
border-left: 4px solid #1e3a5f;
border-bottom: 1px solid #e2e8f0;
line-height: 1.4;
}
h3 {
font-size: 18px;
font-weight: 600;
color: #2c5282;
margin: 24px 0 12px 0;
line-height: 1.4;
}
h4 {
font-size: 16px;
font-weight: 600;
color: #1a1a2e;
margin: 20px 0 10px 0;
line-height: 1.4;
}
p {
font-size: 16px;
line-height: 1.8;
color: #1a1a2e;
margin: 12px 0;
}
strong {
font-weight: 700;
color: #1e3a5f;
}
em {
font-style: italic;
color: #4a5568;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: #f0f4f8;
color: #1e3a5f;
padding: 2px 6px;
border-radius: 4px;
}
pre {
background: #1e3a5f;
color: #e2e8f0;
padding: 16px;
border-radius: 6px;
overflow-x: auto;
margin: 16px 0;
line-height: 1.6;
}
pre code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: none;
color: #e2e8f0;
padding: 0;
border-radius: 0;
}
blockquote {
border-left: 4px solid #1e3a5f;
background: #f0f4f8;
margin: 16px 0;
padding: 12px 16px;
border-radius: 0 6px 6px 0;
color: #2c5282;
}
blockquote p {
margin: 8px 0;
color: #2c5282;
}
ul {
padding-left: 24px;
margin: 12px 0;
}
ol {
padding-left: 24px;
margin: 12px 0;
}
li {
font-size: 16px;
line-height: 1.8;
color: #1a1a2e;
margin: 6px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 15px;
}
thead {
background: #1e3a5f;
}
th {
background: #1e3a5f;
color: #ffffff;
font-weight: 600;
padding: 10px 14px;
text-align: left;
border: 1px solid #1e3a5f;
}
td {
padding: 10px 14px;
border: 1px solid #e2e8f0;
color: #1a1a2e;
}
tr {
background: #ffffff;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 24px auto;
border-radius: 6px;
}
a {
color: #2c5282;
text-decoration: none;
font-weight: 500;
}
hr {
border: none;
border-top: 2px solid #e2e8f0;
margin: 24px 0;
}

View file

@ -0,0 +1,199 @@
name: "bytedance"
description: "字节跳动风:白底品牌蓝,现代无衬线,大间距,适合科技产品内容"
colors:
primary: "#1966FF"
secondary: "#4e8fff"
text: "#1f2329"
text_light: "#646a73"
background: "#ffffff"
code_bg: "#f5f6f7"
code_color: "#1966FF"
quote_border: "#1966FF"
quote_bg: "#f0f5ff"
border_radius: "8px"
darkmode:
background: "#1a1a1a"
text: "#e8e8e8"
text_light: "#a0a0a0"
primary: "#4e8fff"
code_bg: "#2a2a2a"
code_color: "#6ea8fe"
quote_bg: "#1e2a3a"
quote_border: "#4e8fff"
base_css: |
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 16px;
line-height: 2;
color: #1f2329;
background: #ffffff;
max-width: 720px;
margin: 0 auto;
padding: 24px;
word-wrap: break-word;
letter-spacing: 0.02em;
}
h1 {
font-size: 28px;
font-weight: 800;
color: #1f2329;
margin: 40px 0 20px 0;
padding-bottom: 14px;
border-bottom: 3px solid #1966FF;
line-height: 1.4;
letter-spacing: -0.01em;
}
h2 {
font-size: 22px;
font-weight: 700;
color: #1f2329;
margin: 36px 0 16px 0;
padding: 10px 0 10px 14px;
border-left: 4px solid #1966FF;
line-height: 1.4;
}
h3 {
font-size: 18px;
font-weight: 600;
color: #1966FF;
margin: 28px 0 14px 0;
line-height: 1.4;
}
h4 {
font-size: 16px;
font-weight: 600;
color: #1f2329;
margin: 24px 0 12px 0;
line-height: 1.4;
}
p {
font-size: 16px;
line-height: 2;
color: #1f2329;
margin: 16px 0;
}
strong {
font-weight: 700;
color: #1966FF;
}
em {
font-style: italic;
color: #646a73;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: #f5f6f7;
color: #1966FF;
padding: 2px 8px;
border-radius: 4px;
}
pre {
background: #f5f6f7;
color: #1f2329;
padding: 20px;
border-radius: 8px;
overflow-x: auto;
margin: 20px 0;
line-height: 1.6;
border: 1px solid #e5e6e8;
}
pre code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: none;
color: #1f2329;
padding: 0;
border-radius: 0;
}
blockquote {
border-left: 4px solid #1966FF;
background: #f0f5ff;
margin: 20px 0;
padding: 16px 20px;
border-radius: 0 8px 8px 0;
color: #1f2329;
}
blockquote p {
margin: 8px 0;
color: #1f2329;
}
ul {
padding-left: 28px;
margin: 16px 0;
}
ol {
padding-left: 28px;
margin: 16px 0;
}
li {
font-size: 16px;
line-height: 2;
color: #1f2329;
margin: 8px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
font-size: 15px;
}
thead {
background: #1966FF;
}
th {
background: #1966FF;
color: #ffffff;
font-weight: 600;
padding: 12px 16px;
text-align: left;
border: 1px solid #1966FF;
}
td {
padding: 12px 16px;
border: 1px solid #e5e6e8;
color: #1f2329;
}
tr {
background: #ffffff;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 28px auto;
border-radius: 8px;
}
a {
color: #1966FF;
text-decoration: none;
font-weight: 500;
}
hr {
border: none;
border-top: 1px solid #e5e6e8;
margin: 32px 0;
}

View file

@ -0,0 +1,198 @@
name: "elegant-rose"
description: "优雅玫瑰风格:浅粉底玫瑰色点缀,温柔精致,适合女性生活和时尚内容"
colors:
primary: "#be185d"
secondary: "#db2777"
text: "#3d1f2e"
text_light: "#7a5068"
background: "#fdf2f8"
code_bg: "#fce7f3"
code_color: "#be185d"
quote_border: "#be185d"
quote_bg: "#fce7f3"
border_radius: "12px"
darkmode:
background: "#1a0f14"
text: "#e8d0dc"
text_light: "#a07888"
primary: "#f472b6"
code_bg: "#2a1520"
code_color: "#f9a8d4"
quote_bg: "#221018"
quote_border: "#f472b6"
base_css: |
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 16px;
line-height: 1.85;
color: #3d1f2e;
background: #fdf2f8;
max-width: 720px;
margin: 0 auto;
padding: 20px;
word-wrap: break-word;
}
h1 {
font-size: 26px;
font-weight: 700;
color: #be185d;
margin: 32px 0 16px 0;
text-align: center;
padding-bottom: 12px;
border-bottom: 2px solid #f9a8d4;
line-height: 1.4;
}
h2 {
font-size: 22px;
font-weight: 700;
color: #be185d;
margin: 28px 0 14px 0;
padding: 8px 0 8px 12px;
border-left: 4px solid #be185d;
line-height: 1.4;
}
h3 {
font-size: 18px;
font-weight: 600;
color: #db2777;
margin: 24px 0 12px 0;
line-height: 1.4;
}
h4 {
font-size: 16px;
font-weight: 600;
color: #3d1f2e;
margin: 20px 0 10px 0;
line-height: 1.4;
}
p {
font-size: 16px;
line-height: 1.85;
color: #3d1f2e;
margin: 12px 0;
}
strong {
font-weight: 700;
color: #be185d;
}
em {
font-style: italic;
color: #7a5068;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: #fce7f3;
color: #be185d;
padding: 2px 6px;
border-radius: 6px;
}
pre {
background: #fce7f3;
color: #3d1f2e;
padding: 16px;
border-radius: 12px;
overflow-x: auto;
margin: 16px 0;
line-height: 1.6;
border: 1px solid #fbcfe8;
}
pre code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: none;
color: #3d1f2e;
padding: 0;
border-radius: 0;
}
blockquote {
border-left: 4px solid #be185d;
background: #fce7f3;
margin: 16px 0;
padding: 12px 16px;
border-radius: 0 12px 12px 0;
color: #7a5068;
}
blockquote p {
margin: 8px 0;
color: #7a5068;
}
ul {
padding-left: 24px;
margin: 12px 0;
}
ol {
padding-left: 24px;
margin: 12px 0;
}
li {
font-size: 16px;
line-height: 1.85;
color: #3d1f2e;
margin: 6px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 15px;
}
thead {
background: #be185d;
}
th {
background: #be185d;
color: #ffffff;
font-weight: 600;
padding: 10px 14px;
text-align: left;
border: 1px solid #be185d;
}
td {
padding: 10px 14px;
border: 1px solid #fbcfe8;
color: #3d1f2e;
}
tr {
background: #fdf2f8;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 24px auto;
border-radius: 12px;
}
a {
color: #be185d;
text-decoration: none;
font-weight: 500;
}
hr {
border: none;
border-top: 1px solid #fbcfe8;
margin: 24px 0;
}

View file

@ -0,0 +1,197 @@
name: "focus-red"
description: "聚焦红风格:白底中国红标题和引用边框,醒目有力,适合新闻评论和观点输出"
colors:
primary: "#dc2626"
secondary: "#ef4444"
text: "#1a1a1a"
text_light: "#555555"
background: "#ffffff"
code_bg: "#fef2f2"
code_color: "#b91c1c"
quote_border: "#dc2626"
quote_bg: "#fef2f2"
border_radius: "6px"
darkmode:
background: "#1a0f0f"
text: "#e8d8d8"
text_light: "#a08888"
primary: "#f87171"
code_bg: "#2a1515"
code_color: "#fca5a5"
quote_bg: "#221010"
quote_border: "#f87171"
base_css: |
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 16px;
line-height: 1.8;
color: #1a1a1a;
background: #ffffff;
max-width: 720px;
margin: 0 auto;
padding: 20px;
word-wrap: break-word;
}
h1 {
font-size: 26px;
font-weight: 800;
color: #dc2626;
margin: 32px 0 16px 0;
padding-bottom: 12px;
border-bottom: 3px solid #dc2626;
line-height: 1.4;
}
h2 {
font-size: 22px;
font-weight: 700;
color: #dc2626;
margin: 28px 0 14px 0;
padding: 8px 0 8px 12px;
border-left: 4px solid #dc2626;
line-height: 1.4;
}
h3 {
font-size: 18px;
font-weight: 600;
color: #1a1a1a;
margin: 24px 0 12px 0;
line-height: 1.4;
}
h4 {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
margin: 20px 0 10px 0;
line-height: 1.4;
}
p {
font-size: 16px;
line-height: 1.8;
color: #1a1a1a;
margin: 12px 0;
}
strong {
font-weight: 700;
color: #dc2626;
}
em {
font-style: italic;
color: #555555;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: #fef2f2;
color: #b91c1c;
padding: 2px 6px;
border-radius: 4px;
}
pre {
background: #fef2f2;
color: #1a1a1a;
padding: 16px;
border-radius: 6px;
overflow-x: auto;
margin: 16px 0;
line-height: 1.6;
border: 1px solid #fecaca;
}
pre code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: none;
color: #1a1a1a;
padding: 0;
border-radius: 0;
}
blockquote {
border-left: 4px solid #dc2626;
background: #fef2f2;
margin: 16px 0;
padding: 12px 16px;
border-radius: 0 6px 6px 0;
color: #333333;
}
blockquote p {
margin: 8px 0;
color: #333333;
}
ul {
padding-left: 24px;
margin: 12px 0;
}
ol {
padding-left: 24px;
margin: 12px 0;
}
li {
font-size: 16px;
line-height: 1.8;
color: #1a1a1a;
margin: 6px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 15px;
}
thead {
background: #dc2626;
}
th {
background: #dc2626;
color: #ffffff;
font-weight: 700;
padding: 10px 14px;
text-align: left;
border: 1px solid #dc2626;
}
td {
padding: 10px 14px;
border: 1px solid #e5e7eb;
color: #1a1a1a;
}
tr {
background: #ffffff;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 24px auto;
border-radius: 6px;
}
a {
color: #dc2626;
text-decoration: none;
font-weight: 500;
}
hr {
border: none;
border-top: 2px solid #fecaca;
margin: 24px 0;
}

198
dist/openclaw/toolkit/themes/github.yaml vendored Normal file
View file

@ -0,0 +1,198 @@
name: "github"
description: "GitHub风格白底蓝色链接等宽代码块简洁清晰适合技术文档和开发者内容"
colors:
primary: "#0969da"
secondary: "#0550ae"
text: "#1f2328"
text_light: "#656d76"
background: "#ffffff"
code_bg: "#f6f8fa"
code_color: "#0550ae"
quote_border: "#d0d7de"
quote_bg: "#f6f8fa"
border_radius: "6px"
darkmode:
background: "#0d1117"
text: "#e6edf3"
text_light: "#8b949e"
primary: "#58a6ff"
code_bg: "#161b22"
code_color: "#79c0ff"
quote_bg: "#161b22"
quote_border: "#30363d"
base_css: |
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 16px;
line-height: 1.75;
color: #1f2328;
background: #ffffff;
max-width: 720px;
margin: 0 auto;
padding: 20px;
word-wrap: break-word;
}
h1 {
font-size: 26px;
font-weight: 600;
color: #1f2328;
margin: 32px 0 16px 0;
padding-bottom: 10px;
border-bottom: 1px solid #d1d9e0;
line-height: 1.4;
}
h2 {
font-size: 22px;
font-weight: 600;
color: #1f2328;
margin: 28px 0 14px 0;
padding-bottom: 8px;
border-bottom: 1px solid #d1d9e0;
line-height: 1.4;
}
h3 {
font-size: 18px;
font-weight: 600;
color: #1f2328;
margin: 24px 0 12px 0;
line-height: 1.4;
}
h4 {
font-size: 16px;
font-weight: 600;
color: #1f2328;
margin: 20px 0 10px 0;
line-height: 1.4;
}
p {
font-size: 16px;
line-height: 1.75;
color: #1f2328;
margin: 12px 0;
}
strong {
font-weight: 600;
color: #1f2328;
}
em {
font-style: italic;
color: #1f2328;
}
code {
font-family: ui-monospace, "SFMono-Regular", "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
font-size: 13.6px;
background: rgba(175,184,193,0.2);
color: #1f2328;
padding: 3px 6px;
border-radius: 6px;
}
pre {
background: #f6f8fa;
color: #1f2328;
padding: 16px;
border-radius: 6px;
overflow-x: auto;
margin: 16px 0;
line-height: 1.5;
border: 1px solid #d1d9e0;
}
pre code {
font-family: ui-monospace, "SFMono-Regular", "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
font-size: 13.6px;
background: none;
color: #1f2328;
padding: 0;
border-radius: 0;
}
blockquote {
border-left: 4px solid #d0d7de;
background: transparent;
margin: 16px 0;
padding: 4px 16px;
border-radius: 0;
color: #656d76;
}
blockquote p {
margin: 8px 0;
color: #656d76;
}
ul {
padding-left: 28px;
margin: 12px 0;
}
ol {
padding-left: 28px;
margin: 12px 0;
}
li {
font-size: 16px;
line-height: 1.75;
color: #1f2328;
margin: 4px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 15px;
}
thead {
background: #f6f8fa;
}
th {
background: #f6f8fa;
color: #1f2328;
font-weight: 600;
padding: 8px 14px;
text-align: left;
border: 1px solid #d1d9e0;
}
td {
padding: 8px 14px;
border: 1px solid #d1d9e0;
color: #1f2328;
}
tr {
background: #ffffff;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 24px auto;
border-radius: 6px;
}
a {
color: #0969da;
text-decoration: none;
font-weight: 400;
}
hr {
border: none;
height: 2px;
background: #d1d9e0;
margin: 24px 0;
}

204
dist/openclaw/toolkit/themes/ink.yaml vendored Normal file
View file

@ -0,0 +1,204 @@
name: "ink"
description: "水墨中国风:宣纸底墨色文字,中文衬线字体,留白疏朗,适合文化和人文内容"
colors:
primary: "#4a4a4a"
secondary: "#6b6b6b"
text: "#1a1a1a"
text_light: "#666666"
background: "#f8f5f0"
code_bg: "#f0ebe3"
code_color: "#555555"
quote_border: "#999999"
quote_bg: "#f4f0e8"
border_radius: "2px"
darkmode:
background: "#1a1816"
text: "#d8d2c8"
text_light: "#9a9488"
primary: "#b0a898"
code_bg: "#252220"
code_color: "#c0b8a8"
quote_bg: "#222018"
quote_border: "#706858"
base_css: |
body {
font-family: "Songti SC", "SimSun", "Noto Serif SC", Georgia, serif;
font-size: 16px;
line-height: 2;
color: #1a1a1a;
background: #f8f5f0;
max-width: 680px;
margin: 0 auto;
padding: 28px;
word-wrap: break-word;
}
h1 {
font-size: 28px;
font-weight: 700;
color: #1a1a1a;
margin: 48px 0 24px 0;
text-align: center;
line-height: 1.4;
letter-spacing: 0.1em;
}
h2 {
font-size: 22px;
font-weight: 700;
color: #1a1a1a;
margin: 40px 0 16px 0;
text-align: center;
padding-bottom: 12px;
line-height: 1.4;
letter-spacing: 0.05em;
}
h3 {
font-size: 18px;
font-weight: 600;
color: #333333;
margin: 32px 0 14px 0;
line-height: 1.4;
letter-spacing: 0.03em;
}
h4 {
font-size: 16px;
font-weight: 600;
color: #333333;
margin: 24px 0 12px 0;
line-height: 1.4;
}
p {
font-size: 16px;
line-height: 2;
color: #1a1a1a;
margin: 16px 0;
text-indent: 2em;
}
strong {
font-weight: 700;
color: #1a1a1a;
}
em {
font-style: italic;
color: #666666;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: #f0ebe3;
color: #555555;
padding: 2px 6px;
border-radius: 2px;
}
pre {
background: #f0ebe3;
color: #1a1a1a;
padding: 16px;
border-radius: 2px;
overflow-x: auto;
margin: 20px 0;
line-height: 1.6;
border: 1px solid #ddd5c8;
}
pre code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: none;
color: #1a1a1a;
padding: 0;
border-radius: 0;
}
blockquote {
border-left: 3px solid #999999;
background: #f4f0e8;
margin: 20px 0;
padding: 14px 18px;
border-radius: 0 2px 2px 0;
color: #555555;
font-style: italic;
}
blockquote p {
margin: 8px 0;
color: #555555;
text-indent: 0;
}
ul {
padding-left: 24px;
margin: 16px 0;
}
ol {
padding-left: 24px;
margin: 16px 0;
}
li {
font-size: 16px;
line-height: 2;
color: #1a1a1a;
margin: 8px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
font-size: 15px;
}
thead {
background: #4a4a4a;
}
th {
background: #4a4a4a;
color: #f8f5f0;
font-weight: 600;
padding: 10px 14px;
text-align: left;
border: 1px solid #4a4a4a;
}
td {
padding: 10px 14px;
border: 1px solid #ddd5c8;
color: #1a1a1a;
}
tr {
background: #f8f5f0;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 32px auto;
border-radius: 2px;
}
a {
color: #555555;
text-decoration: underline;
}
hr {
border: none;
text-align: center;
margin: 36px 0;
height: 20px;
background: transparent;
border-bottom: 1px solid #ccc5b8;
}

View file

@ -0,0 +1,197 @@
name: "midnight"
description: "午夜深色主题:深蓝黑底白色文字,蓝色高亮,适合技术和深夜阅读内容"
colors:
primary: "#60a5fa"
secondary: "#93c5fd"
text: "#e2e8f0"
text_light: "#94a3b8"
background: "#0f172a"
code_bg: "#1e293b"
code_color: "#7dd3fc"
quote_border: "#60a5fa"
quote_bg: "#172040"
border_radius: "8px"
darkmode:
background: "#0f172a"
text: "#e2e8f0"
text_light: "#94a3b8"
primary: "#60a5fa"
code_bg: "#1e293b"
code_color: "#7dd3fc"
quote_bg: "#172040"
quote_border: "#60a5fa"
base_css: |
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 16px;
line-height: 1.8;
color: #e2e8f0;
background: #0f172a;
max-width: 720px;
margin: 0 auto;
padding: 20px;
word-wrap: break-word;
}
h1 {
font-size: 26px;
font-weight: 700;
color: #f1f5f9;
margin: 32px 0 16px 0;
padding-bottom: 12px;
border-bottom: 2px solid #60a5fa;
line-height: 1.4;
}
h2 {
font-size: 22px;
font-weight: 700;
color: #60a5fa;
margin: 28px 0 14px 0;
padding: 8px 0 8px 12px;
border-left: 4px solid #60a5fa;
line-height: 1.4;
}
h3 {
font-size: 18px;
font-weight: 600;
color: #93c5fd;
margin: 24px 0 12px 0;
line-height: 1.4;
}
h4 {
font-size: 16px;
font-weight: 600;
color: #cbd5e1;
margin: 20px 0 10px 0;
line-height: 1.4;
}
p {
font-size: 16px;
line-height: 1.8;
color: #e2e8f0;
margin: 12px 0;
}
strong {
font-weight: 700;
color: #60a5fa;
}
em {
font-style: italic;
color: #94a3b8;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: #1e293b;
color: #7dd3fc;
padding: 2px 6px;
border-radius: 4px;
}
pre {
background: #1e293b;
color: #e2e8f0;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
margin: 16px 0;
line-height: 1.6;
border: 1px solid #334155;
}
pre code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: none;
color: #e2e8f0;
padding: 0;
border-radius: 0;
}
blockquote {
border-left: 4px solid #60a5fa;
background: #172040;
margin: 16px 0;
padding: 12px 16px;
border-radius: 0 8px 8px 0;
color: #cbd5e1;
}
blockquote p {
margin: 8px 0;
color: #cbd5e1;
}
ul {
padding-left: 24px;
margin: 12px 0;
}
ol {
padding-left: 24px;
margin: 12px 0;
}
li {
font-size: 16px;
line-height: 1.8;
color: #e2e8f0;
margin: 6px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 15px;
}
thead {
background: #1e3a5f;
}
th {
background: #1e3a5f;
color: #f1f5f9;
font-weight: 600;
padding: 10px 14px;
text-align: left;
border: 1px solid #334155;
}
td {
padding: 10px 14px;
border: 1px solid #334155;
color: #e2e8f0;
}
tr {
background: #0f172a;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 24px auto;
border-radius: 8px;
}
a {
color: #60a5fa;
text-decoration: none;
font-weight: 500;
}
hr {
border: none;
border-top: 1px solid #334155;
margin: 24px 0;
}

View file

@ -0,0 +1,202 @@
name: "minimal-gold"
description: "极简金色风格:白底金色细线点缀,奢华但克制,适合高端品牌和精品内容"
colors:
primary: "#b8860b"
secondary: "#d4a843"
text: "#2a2a2a"
text_light: "#6b6b6b"
background: "#ffffff"
code_bg: "#faf8f3"
code_color: "#8b6914"
quote_border: "#b8860b"
quote_bg: "#fdfbf5"
border_radius: "4px"
darkmode:
background: "#141210"
text: "#e0dcd0"
text_light: "#9a9488"
primary: "#d4a843"
code_bg: "#1e1c18"
code_color: "#e0c060"
quote_bg: "#1a1810"
quote_border: "#d4a843"
base_css: |
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 16px;
line-height: 1.8;
color: #2a2a2a;
background: #ffffff;
max-width: 720px;
margin: 0 auto;
padding: 20px;
word-wrap: break-word;
letter-spacing: 0.01em;
}
h1 {
font-size: 26px;
font-weight: 600;
color: #2a2a2a;
margin: 36px 0 18px 0;
text-align: center;
padding-bottom: 14px;
border-bottom: 1px solid #b8860b;
line-height: 1.4;
letter-spacing: 0.05em;
}
h2 {
font-size: 21px;
font-weight: 600;
color: #2a2a2a;
margin: 30px 0 14px 0;
padding-bottom: 8px;
border-bottom: 1px solid #d4c59a;
line-height: 1.4;
}
h3 {
font-size: 18px;
font-weight: 600;
color: #b8860b;
margin: 24px 0 12px 0;
line-height: 1.4;
}
h4 {
font-size: 16px;
font-weight: 600;
color: #2a2a2a;
margin: 20px 0 10px 0;
line-height: 1.4;
}
p {
font-size: 16px;
line-height: 1.8;
color: #2a2a2a;
margin: 12px 0;
}
strong {
font-weight: 700;
color: #b8860b;
}
em {
font-style: italic;
color: #6b6b6b;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: #faf8f3;
color: #8b6914;
padding: 2px 6px;
border-radius: 4px;
}
pre {
background: #faf8f3;
color: #2a2a2a;
padding: 16px;
border-radius: 4px;
overflow-x: auto;
margin: 16px 0;
line-height: 1.6;
border: 1px solid #e8e0c8;
}
pre code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: none;
color: #2a2a2a;
padding: 0;
border-radius: 0;
}
blockquote {
border-left: 2px solid #b8860b;
background: #fdfbf5;
margin: 16px 0;
padding: 12px 16px;
border-radius: 0 4px 4px 0;
color: #6b6b6b;
}
blockquote p {
margin: 8px 0;
color: #6b6b6b;
}
ul {
padding-left: 24px;
margin: 12px 0;
}
ol {
padding-left: 24px;
margin: 12px 0;
}
li {
font-size: 16px;
line-height: 1.8;
color: #2a2a2a;
margin: 6px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 15px;
}
thead {
background: #2a2a2a;
}
th {
background: #2a2a2a;
color: #d4a843;
font-weight: 600;
padding: 10px 14px;
text-align: left;
border: 1px solid #2a2a2a;
letter-spacing: 0.03em;
}
td {
padding: 10px 14px;
border: 1px solid #e8e0c8;
color: #2a2a2a;
}
tr {
background: #ffffff;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 24px auto;
border-radius: 4px;
}
a {
color: #b8860b;
text-decoration: none;
font-weight: 500;
}
hr {
border: none;
border-top: 1px solid #d4c59a;
margin: 28px auto;
width: 40%;
}

View file

@ -0,0 +1,195 @@
name: "minimal"
description: "极简黑白灰风格,无色彩干扰,内容至上"
colors:
primary: "#333333"
secondary: "#666666"
text: "#333333"
text_light: "#666666"
background: "#ffffff"
code_bg: "#f5f5f5"
code_color: "#d73a49"
quote_border: "#cccccc"
quote_bg: "#f9f9f9"
border_radius: "4px"
darkmode:
background: "#1a1a1a"
text: "#c0c0c0"
text_light: "#888888"
primary: "#e0e0e0"
code_bg: "#252525"
code_color: "#c0c0c0"
quote_bg: "#222222"
quote_border: "#555555"
base_css: |
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 16px;
line-height: 1.8;
color: #333333;
background: #ffffff;
max-width: 720px;
margin: 0 auto;
padding: 20px;
word-wrap: break-word;
}
h1 {
font-size: 26px;
font-weight: 700;
color: #1a1a1a;
margin: 32px 0 16px 0;
text-align: center;
line-height: 1.4;
}
h2 {
font-size: 22px;
font-weight: 700;
color: #1a1a1a;
margin: 28px 0 14px 0;
padding-bottom: 8px;
border-bottom: 1px solid #e0e0e0;
line-height: 1.4;
}
h3 {
font-size: 18px;
font-weight: 600;
color: #333333;
margin: 24px 0 12px 0;
line-height: 1.4;
}
h4 {
font-size: 16px;
font-weight: 600;
color: #333333;
margin: 20px 0 10px 0;
line-height: 1.4;
}
p {
font-size: 16px;
line-height: 1.8;
color: #333333;
margin: 12px 0;
}
strong {
font-weight: 700;
color: #1a1a1a;
}
em {
font-style: italic;
color: #333333;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: #f5f5f5;
color: #d73a49;
padding: 2px 6px;
border-radius: 4px;
}
pre {
background: #f5f5f5;
color: #333333;
padding: 16px;
border-radius: 4px;
overflow-x: auto;
margin: 16px 0;
line-height: 1.6;
border: 1px solid #e0e0e0;
}
pre code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: none;
color: #333333;
padding: 0;
border-radius: 0;
}
blockquote {
border-left: 3px solid #cccccc;
background: #f9f9f9;
margin: 16px 0;
padding: 12px 16px;
border-radius: 0 4px 4px 0;
color: #666666;
}
blockquote p {
margin: 8px 0;
color: #666666;
}
ul {
padding-left: 24px;
margin: 12px 0;
}
ol {
padding-left: 24px;
margin: 12px 0;
}
li {
font-size: 16px;
line-height: 1.8;
color: #333333;
margin: 6px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 15px;
}
thead {
background: rgba(0,0,0,0.03);
}
th {
background: rgba(0,0,0,0.03);
color: #333333;
font-weight: 600;
padding: 10px 14px;
text-align: left;
border: 1px solid #e0e0e0;
}
td {
padding: 10px 14px;
border: 1px solid #e0e0e0;
color: #333333;
}
tr {
background: #ffffff;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 24px auto;
border-radius: 4px;
}
a {
color: #333333;
text-decoration: underline;
}
hr {
border: none;
border-top: 1px solid #e0e0e0;
margin: 24px 0;
}

View file

@ -0,0 +1,206 @@
name: "newspaper"
description: "经典报纸风格:米黄底深棕文字,衬线字体质感,适合深度报道和评论"
colors:
primary: "#8b4513"
secondary: "#a0522d"
text: "#2c2416"
text_light: "#5c4a3a"
background: "#f5f0e8"
code_bg: "#ede7db"
code_color: "#8b4513"
quote_border: "#8b4513"
quote_bg: "#f0eade"
border_radius: "2px"
darkmode:
background: "#1e1a14"
text: "#ddd5c8"
text_light: "#a09580"
primary: "#c8915a"
code_bg: "#2a2418"
code_color: "#d4a574"
quote_bg: "#28221a"
quote_border: "#c8915a"
base_css: |
body {
font-family: Georgia, "Songti SC", "SimSun", "Noto Serif SC", serif;
font-size: 16px;
line-height: 1.9;
color: #2c2416;
background: #f5f0e8;
max-width: 720px;
margin: 0 auto;
padding: 20px;
word-wrap: break-word;
}
h1 {
font-size: 28px;
font-weight: 700;
color: #2c2416;
margin: 36px 0 16px 0;
text-align: center;
padding-bottom: 12px;
border-bottom: 3px double #8b4513;
line-height: 1.3;
letter-spacing: 0.03em;
}
h2 {
font-size: 22px;
font-weight: 700;
color: #2c2416;
margin: 30px 0 14px 0;
padding-bottom: 8px;
border-bottom: 1px solid #8b4513;
line-height: 1.4;
}
h3 {
font-size: 18px;
font-weight: 700;
color: #5c4a3a;
margin: 24px 0 12px 0;
line-height: 1.4;
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 16px;
}
h4 {
font-size: 16px;
font-weight: 700;
color: #5c4a3a;
margin: 20px 0 10px 0;
line-height: 1.4;
}
p {
font-size: 16px;
line-height: 1.9;
color: #2c2416;
margin: 14px 0;
text-align: justify;
}
strong {
font-weight: 700;
color: #2c2416;
}
em {
font-style: italic;
color: #5c4a3a;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: #ede7db;
color: #8b4513;
padding: 2px 6px;
border-radius: 2px;
}
pre {
background: #ede7db;
color: #2c2416;
padding: 16px;
border-radius: 2px;
overflow-x: auto;
margin: 16px 0;
line-height: 1.6;
border: 1px solid #d4cbb8;
}
pre code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: none;
color: #2c2416;
padding: 0;
border-radius: 0;
}
blockquote {
border-left: 3px solid #8b4513;
background: #f0eade;
margin: 16px 0;
padding: 12px 16px;
border-radius: 0 2px 2px 0;
color: #5c4a3a;
font-style: italic;
}
blockquote p {
margin: 8px 0;
color: #5c4a3a;
}
ul {
padding-left: 24px;
margin: 14px 0;
}
ol {
padding-left: 24px;
margin: 14px 0;
}
li {
font-size: 16px;
line-height: 1.9;
color: #2c2416;
margin: 6px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 15px;
}
thead {
background: #8b4513;
}
th {
background: #8b4513;
color: #f5f0e8;
font-weight: 700;
padding: 10px 14px;
text-align: left;
border: 1px solid #8b4513;
}
td {
padding: 10px 14px;
border: 1px solid #d4cbb8;
color: #2c2416;
}
tr {
background: #f5f0e8;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 24px auto;
border-radius: 2px;
border: 1px solid #d4cbb8;
}
a {
color: #8b4513;
text-decoration: underline;
font-weight: 500;
}
hr {
border: none;
border-top: 1px solid #d4cbb8;
margin: 28px auto;
width: 60%;
}

View file

@ -0,0 +1,197 @@
name: "professional-clean"
description: "干净专业的企业公众号风格,适合大多数商业内容"
colors:
primary: "#2563eb"
secondary: "#3b82f6"
text: "#333333"
text_light: "#666666"
background: "#ffffff"
code_bg: "#1e293b"
code_color: "#e2e8f0"
quote_border: "#2563eb"
quote_bg: "#eff6ff"
border_radius: "8px"
darkmode:
background: "#1e1e1e"
text: "#c8c8c8"
text_light: "#999999"
primary: "#6aadff"
code_bg: "#2d2d2d"
code_color: "#d4d4d4"
quote_bg: "#252525"
quote_border: "#4a90d9"
base_css: |
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 16px;
line-height: 1.8;
color: #333333;
background: #ffffff;
max-width: 720px;
margin: 0 auto;
padding: 20px;
word-wrap: break-word;
}
h1 {
font-size: 26px;
font-weight: 700;
color: #1a1a1a;
margin: 32px 0 16px 0;
padding-bottom: 12px;
border-bottom: 2px solid #2563eb;
line-height: 1.4;
}
h2 {
font-size: 22px;
font-weight: 700;
color: #1a1a1a;
margin: 28px 0 14px 0;
padding: 8px 0 8px 12px;
border-left: 4px solid #2563eb;
border-bottom: 1px solid #e5e7eb;
line-height: 1.4;
}
h3 {
font-size: 18px;
font-weight: 600;
color: #333333;
margin: 24px 0 12px 0;
line-height: 1.4;
}
h4 {
font-size: 16px;
font-weight: 600;
color: #333333;
margin: 20px 0 10px 0;
line-height: 1.4;
}
p {
font-size: 16px;
line-height: 1.8;
color: #333333;
margin: 12px 0;
}
strong {
font-weight: 700;
color: #1a1a1a;
}
em {
font-style: italic;
color: #333333;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: #f1f5f9;
color: #d946ef;
padding: 2px 6px;
border-radius: 4px;
}
pre {
background: #1e293b;
color: #e2e8f0;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
margin: 16px 0;
line-height: 1.6;
}
pre code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: none;
color: #e2e8f0;
padding: 0;
border-radius: 0;
}
blockquote {
border-left: 4px solid #2563eb;
background: #eff6ff;
margin: 16px 0;
padding: 12px 16px;
border-radius: 0 8px 8px 0;
color: #333333;
}
blockquote p {
margin: 8px 0;
color: #333333;
}
ul {
padding-left: 24px;
margin: 12px 0;
}
ol {
padding-left: 24px;
margin: 12px 0;
}
li {
font-size: 16px;
line-height: 1.8;
color: #333333;
margin: 6px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 15px;
}
thead {
background: #2563eb;
}
th {
background: #2563eb;
color: #ffffff;
font-weight: 600;
padding: 10px 14px;
text-align: left;
border: 1px solid #2563eb;
}
td {
padding: 10px 14px;
border: 1px solid #e5e7eb;
color: #333333;
}
tr {
background: #ffffff;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 24px auto;
border-radius: 8px;
}
a {
color: #2563eb;
text-decoration: none;
font-weight: 500;
}
hr {
border: none;
border-top: 1px solid #e5e7eb;
margin: 24px 0;
}

198
dist/openclaw/toolkit/themes/sspai.yaml vendored Normal file
View file

@ -0,0 +1,198 @@
name: "sspai"
description: "少数派风格:暖白底红色点缀,清爽文艺,适合数码生活和效率工具内容"
colors:
primary: "#c7372f"
secondary: "#d4524b"
text: "#333333"
text_light: "#888888"
background: "#fafaf7"
code_bg: "#f5f2ed"
code_color: "#c7372f"
quote_border: "#c7372f"
quote_bg: "#fdf5f4"
border_radius: "6px"
darkmode:
background: "#1c1c1a"
text: "#e0ddd8"
text_light: "#9a9790"
primary: "#e05a52"
code_bg: "#2a2825"
code_color: "#e87c76"
quote_bg: "#2a2220"
quote_border: "#e05a52"
base_css: |
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 16px;
line-height: 1.85;
color: #333333;
background: #fafaf7;
max-width: 720px;
margin: 0 auto;
padding: 20px;
word-wrap: break-word;
}
h1 {
font-size: 26px;
font-weight: 700;
color: #1a1a1a;
margin: 36px 0 18px 0;
text-align: center;
line-height: 1.4;
}
h2 {
font-size: 21px;
font-weight: 700;
color: #c7372f;
margin: 30px 0 14px 0;
padding-bottom: 8px;
border-bottom: 2px solid #c7372f;
line-height: 1.4;
}
h3 {
font-size: 18px;
font-weight: 600;
color: #333333;
margin: 24px 0 12px 0;
padding-left: 10px;
border-left: 3px solid #c7372f;
line-height: 1.4;
}
h4 {
font-size: 16px;
font-weight: 600;
color: #333333;
margin: 20px 0 10px 0;
line-height: 1.4;
}
p {
font-size: 16px;
line-height: 1.85;
color: #333333;
margin: 14px 0;
}
strong {
font-weight: 700;
color: #c7372f;
}
em {
font-style: italic;
color: #666666;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: #f5f2ed;
color: #c7372f;
padding: 2px 6px;
border-radius: 4px;
}
pre {
background: #f5f2ed;
color: #333333;
padding: 16px;
border-radius: 6px;
overflow-x: auto;
margin: 16px 0;
line-height: 1.6;
border: 1px solid #e8e4dc;
}
pre code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: none;
color: #333333;
padding: 0;
border-radius: 0;
}
blockquote {
border-left: 4px solid #c7372f;
background: #fdf5f4;
margin: 16px 0;
padding: 12px 16px;
border-radius: 0 6px 6px 0;
color: #555555;
}
blockquote p {
margin: 8px 0;
color: #555555;
}
ul {
padding-left: 24px;
margin: 14px 0;
}
ol {
padding-left: 24px;
margin: 14px 0;
}
li {
font-size: 16px;
line-height: 1.85;
color: #333333;
margin: 6px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 15px;
}
thead {
background: #c7372f;
}
th {
background: #c7372f;
color: #ffffff;
font-weight: 600;
padding: 10px 14px;
text-align: left;
border: 1px solid #c7372f;
}
td {
padding: 10px 14px;
border: 1px solid #e8e4dc;
color: #333333;
}
tr {
background: #fafaf7;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 24px auto;
border-radius: 6px;
}
a {
color: #c7372f;
text-decoration: none;
font-weight: 500;
}
hr {
border: none;
border-top: 1px solid #e8e4dc;
margin: 28px 0;
}

View file

@ -0,0 +1,205 @@
name: "tech-modern"
description: "科技感蓝紫渐变风格,适合技术和产品类内容"
colors:
primary: "#7c3aed"
secondary: "#3b82f6"
text: "#333333"
text_light: "#666666"
background: "#ffffff"
code_bg: "#282c34"
code_color: "#abb2bf"
quote_border: "#7c3aed"
quote_bg: "#f8f5ff"
border_radius: "8px"
darkmode:
background: "#1a1a2e"
text: "#c8c8c8"
text_light: "#999999"
primary: "#a78bfa"
code_bg: "#1e1e2e"
code_color: "#c8cad8"
quote_bg: "#232340"
quote_border: "#7c3aed"
base_css: |
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 16px;
line-height: 1.8;
color: #333333;
background: #ffffff;
max-width: 720px;
margin: 0 auto;
padding: 20px;
word-wrap: break-word;
}
h1 {
font-size: 26px;
font-weight: 700;
color: #1a1a1a;
margin: 32px 0 16px 0;
padding: 8px 0 8px 14px;
border-left: 5px solid #7c3aed;
line-height: 1.4;
}
h2 {
font-size: 22px;
font-weight: 700;
color: #1a1a1a;
margin: 28px 0 14px 0;
padding-bottom: 10px;
border-bottom: 2px solid transparent;
background-image: linear-gradient(90deg, #7c3aed 0%, #3b82f6 50%, transparent 50%);
background-size: 100% 2px;
background-position: 0 100%;
background-repeat: no-repeat;
line-height: 1.4;
}
h3 {
font-size: 18px;
font-weight: 600;
color: #333333;
margin: 24px 0 12px 0;
padding-left: 12px;
border-left: 3px solid #7c3aed;
line-height: 1.4;
}
h4 {
font-size: 16px;
font-weight: 600;
color: #333333;
margin: 20px 0 10px 0;
line-height: 1.4;
}
p {
font-size: 16px;
line-height: 1.8;
color: #333333;
margin: 12px 0;
}
strong {
font-weight: 700;
color: #7c3aed;
}
em {
font-style: italic;
color: #333333;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: #f5f5f5;
color: #e83e8c;
padding: 2px 6px;
border-radius: 4px;
}
pre {
background: #282c34;
color: #abb2bf;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
margin: 16px 0;
line-height: 1.6;
}
pre code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: none;
color: #abb2bf;
padding: 0;
border-radius: 0;
}
blockquote {
border-left: 4px solid #7c3aed;
background: #f8f5ff;
margin: 16px 0;
padding: 12px 16px;
border-radius: 0 8px 8px 0;
color: #333333;
}
blockquote p {
margin: 8px 0;
color: #333333;
}
ul {
padding-left: 24px;
margin: 12px 0;
list-style-type: disc;
color: #7c3aed;
}
ol {
padding-left: 24px;
margin: 12px 0;
}
li {
font-size: 16px;
line-height: 1.8;
color: #333333;
margin: 6px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 15px;
}
thead {
background: linear-gradient(135deg, #7c3aed 0%, #3b82f6 100%);
color: #ffffff;
}
th {
color: #ffffff;
font-weight: 600;
padding: 10px 14px;
text-align: left;
border: 1px solid #7c3aed;
}
td {
padding: 10px 14px;
border: 1px solid #e5e7eb;
color: #333333;
}
tr {
background: #ffffff;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 24px auto;
border-radius: 8px;
}
a {
color: #7c3aed;
text-decoration: none;
font-weight: 500;
}
hr {
border: none;
height: 2px;
background: linear-gradient(90deg, #7c3aed, #3b82f6, transparent);
margin: 24px 0;
}

View file

@ -0,0 +1,197 @@
name: "warm-editorial"
description: "暖色编辑风格,适合生活方式和文化类内容"
colors:
primary: "#d97706"
secondary: "#ea580c"
text: "#333333"
text_light: "#666666"
background: "#ffffff"
code_bg: "#fef3c7"
code_color: "#92400e"
quote_border: "#d97706"
quote_bg: "#fffbeb"
border_radius: "8px"
darkmode:
background: "#1e1e1e"
text: "#d4c8b8"
text_light: "#a09080"
primary: "#f0a830"
code_bg: "#2a2520"
code_color: "#d4b896"
quote_bg: "#2a2418"
quote_border: "#d97706"
base_css: |
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 16px;
line-height: 1.8;
color: #333333;
background: #ffffff;
max-width: 720px;
margin: 0 auto;
padding: 20px;
word-wrap: break-word;
}
h1 {
font-size: 26px;
font-weight: 700;
color: #1a1a1a;
margin: 32px 0 16px 0;
padding-bottom: 12px;
border-bottom: 2px solid #d97706;
line-height: 1.4;
}
h2 {
font-size: 22px;
font-weight: 700;
color: #1a1a1a;
margin: 28px 0 14px 0;
padding: 8px 0 8px 12px;
border-left: 4px solid #d97706;
line-height: 1.4;
}
h3 {
font-size: 18px;
font-weight: 600;
color: #92400e;
margin: 24px 0 12px 0;
line-height: 1.4;
}
h4 {
font-size: 16px;
font-weight: 600;
color: #92400e;
margin: 20px 0 10px 0;
line-height: 1.4;
}
p {
font-size: 16px;
line-height: 1.8;
color: #333333;
margin: 12px 0;
}
strong {
font-weight: 700;
color: #92400e;
}
em {
font-style: italic;
color: #333333;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: #fef3c7;
color: #92400e;
padding: 2px 6px;
border-radius: 4px;
}
pre {
background: #fef3c7;
color: #92400e;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
margin: 16px 0;
line-height: 1.6;
border: 1px solid #fde68a;
}
pre code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: none;
color: #92400e;
padding: 0;
border-radius: 0;
}
blockquote {
border-left: 4px solid #d97706;
background: #fffbeb;
margin: 16px 0;
padding: 12px 16px;
border-radius: 0 8px 8px 0;
color: #333333;
}
blockquote p {
margin: 8px 0;
color: #333333;
}
ul {
padding-left: 24px;
margin: 12px 0;
}
ol {
padding-left: 24px;
margin: 12px 0;
}
li {
font-size: 16px;
line-height: 1.8;
color: #333333;
margin: 6px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 15px;
}
thead {
background: #d97706;
}
th {
background: #d97706;
color: #ffffff;
font-weight: 600;
padding: 10px 14px;
text-align: left;
border: 1px solid #d97706;
}
td {
padding: 10px 14px;
border: 1px solid #fde68a;
color: #333333;
}
tr {
background: #ffffff;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 24px auto;
border-radius: 8px;
}
a {
color: #d97706;
text-decoration: none;
font-weight: 500;
}
hr {
border: none;
border-top: 1px solid #fde68a;
margin: 24px 0;
}

115
dist/openclaw/toolkit/wechat_api.py vendored Normal file
View file

@ -0,0 +1,115 @@
import time
import mimetypes
import requests
from pathlib import Path
from dataclasses import dataclass
# Token cache
_token_cache: dict = {}
@dataclass
class TokenResult:
access_token: str
expires_at: float # unix timestamp
def get_access_token(appid: str, secret: str, force_refresh: bool = False) -> str:
"""
Get access_token with caching.
Cache key: appid
API: GET https://api.weixin.qq.com/cgi-bin/token
Cache until expires_in - 300 seconds (5 min buffer).
Raise ValueError on API error.
"""
now = time.time()
if not force_refresh and appid in _token_cache:
cached: TokenResult = _token_cache[appid]
if now < cached.expires_at:
return cached.access_token
resp = requests.get(
"https://api.weixin.qq.com/cgi-bin/token",
params={
"grant_type": "client_credential",
"appid": appid,
"secret": secret,
},
)
data = resp.json()
if "access_token" not in data:
errcode = data.get("errcode", "unknown")
errmsg = data.get("errmsg", "unknown error")
raise ValueError(f"WeChat API error: errcode={errcode}, errmsg={errmsg}")
access_token = data["access_token"]
expires_in = data.get("expires_in", 7200)
_token_cache[appid] = TokenResult(
access_token=access_token,
expires_at=now + expires_in - 300,
)
return access_token
def _guess_content_type(file_path: str) -> str:
"""Detect content type from file extension."""
content_type, _ = mimetypes.guess_type(file_path)
return content_type or "application/octet-stream"
def upload_image(access_token: str, image_path: str) -> str:
"""
Upload image for use inside article content.
API: POST https://api.weixin.qq.com/cgi-bin/media/uploadimg
Returns the url string.
Raise ValueError on error.
"""
path = Path(image_path)
content_type = _guess_content_type(image_path)
with open(path, "rb") as f:
resp = requests.post(
"https://api.weixin.qq.com/cgi-bin/media/uploadimg",
params={"access_token": access_token},
files={"media": (path.name, f, content_type)},
)
data = resp.json()
if "url" not in data:
errcode = data.get("errcode", "unknown")
errmsg = data.get("errmsg", "unknown error")
raise ValueError(f"WeChat upload_image error: errcode={errcode}, errmsg={errmsg}")
return data["url"]
def upload_thumb(access_token: str, image_path: str) -> str:
"""
Upload cover image as permanent material.
API: POST https://api.weixin.qq.com/cgi-bin/material/add_material
Returns media_id string.
Raise ValueError on error.
"""
path = Path(image_path)
content_type = _guess_content_type(image_path)
with open(path, "rb") as f:
resp = requests.post(
"https://api.weixin.qq.com/cgi-bin/material/add_material",
params={"access_token": access_token, "type": "image"},
files={"media": (path.name, f, content_type)},
)
data = resp.json()
if "media_id" not in data:
errcode = data.get("errcode", "unknown")
errmsg = data.get("errmsg", "unknown error")
raise ValueError(f"WeChat upload_thumb error: errcode={errcode}, errmsg={errmsg}")
return data["media_id"]

View file

@ -0,0 +1,79 @@
# WeWrite 写作参数(可优化)
# 复制为 writing-config.yaml然后用 optimize loop 迭代调优
# 或手动调整后观察朱雀检测结果
#
# 这个文件是起点,不是最优解。
# 运行: python3 scripts/optimize_loop.py --topic "你的主题" --iterations 10
# 每次迭代会修改 writing-config.yaml 中的参数,保留得分更好的版本。
# 写作人设——影响整体语感和视角
persona: "科技媒体资深编辑写了八年公众号对AI行业有深度认知"
# 句子变化度 (0-1)——越高句长方差越大
sentence_variance: 0.7
# 破句/不完整句比率——每1000字出现几次
broken_sentence_rate: 0.04
# 成语/俗语密度——每段平均出现几次
idiom_density: 0.15
# 口语填充词风格
# literary: 坦白讲、怎么说呢
# casual: 说白了、懂的都懂
# mixed: 混合
# minimal: 尽量少用
filler_style: "mixed"
# 段落节奏
# structured: 匀称AI默认
# chaotic: 剧烈长短交替
# wave: 长→短→长波浪
paragraph_rhythm: "chaotic"
# 自我纠正频率——"不对,准确说是..."
self_correction_rate: 0.02
# 跑题频率
# never / every_500_chars / every_800_chars / every_1200_chars
tangent_frequency: "every_800_chars"
# 真实数据引用密度
# low: 每H2段1条
# medium: 每H2段2条
# high: 每H2段3条+
real_data_density: "high"
# 词汇温度偏向
# cold: 偏专业书面
# warm: 偏日常口语
# hot: 偏网络用语
# balanced: 均匀混搭
word_temperature_bias: "warm"
# 情绪弧线
# flat: 全程平稳AI默认避免
# gradual: 缓慢升温
# restrained_to_burst: 克制→爆发(推荐)
# volatile: 剧烈波动
emotional_arc: "restrained_to_burst"
# 开头策略
# scene: 场景描写
# data: 数据冲击
# question: 反问
# anecdote: 个人经历
# cold_open: 冷开场(直接切入)
opening_style: "scene"
# 收尾策略
# summary: 总结回顾AI默认避免
# open_question: 留一个没答案的问题
# image: 用一个画面收束
# abrupt: 戛然而止
closing_style: "open_question"
# 结构线性度 (0-1)
# 0 = 完全非线性(跳跃、倒叙、插叙)
# 1 = 完全线性(观点→论据→总结)
structure_linearity: 0.3

132
scripts/build_openclaw.py Normal file
View file

@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
Build OpenClaw-compatible SKILL.md from Claude Code source.
Usage:
python3 scripts/build_openclaw.py # output to dist/openclaw/
python3 scripts/build_openclaw.py -o /tmp/oc # custom output dir
"""
import argparse
import re
import shutil
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
# Directories to copy alongside SKILL.md
COPY_DIRS = ["references", "scripts", "toolkit", "personas"]
# Files to copy alongside SKILL.md
COPY_FILES = [
"requirements.txt",
"config.example.yaml",
"style.example.yaml",
"writing-config.example.yaml",
]
# Frontmatter keys to strip (OpenClaw ignores allowed-tools)
STRIP_FRONTMATTER_KEYS = {"allowed-tools"}
def transform_frontmatter(frontmatter: str) -> str:
"""Remove Claude Code-specific frontmatter keys."""
lines = frontmatter.split("\n")
result = []
skip_block = False
for line in lines:
# Check if this line starts a key we want to strip
stripped = line.lstrip()
if any(stripped.startswith(f"{key}:") for key in STRIP_FRONTMATTER_KEYS):
skip_block = True
continue
# If we're in a skip block, skip indented continuation lines (list items)
if skip_block:
if stripped.startswith("- ") or stripped == "":
continue
skip_block = False
result.append(line)
return "\n".join(result)
def transform_body(body: str) -> str:
"""Apply all body transformations."""
# 1. {skill_dir} → {baseDir}
body = body.replace("{skill_dir}", "{baseDir}")
# 2. WebSearch references in instructions (preserve in bash code blocks)
# "WebSearch:" as instruction prefix → "web_search:"
# "WebSearch " in prose → "web_search "
body = re.sub(r'(?m)^WebSearch:', 'web_search:', body)
body = re.sub(r'(?<![`/])WebSearch(?=[ "])', 'web_search', body)
# WebSearch in parentheses/tables: "WebSearch"
body = re.sub(r'(?<=)WebSearch(?=)', 'web_search', body)
# 3. Path convention note
body = body.replace(
"本文档中 `{baseDir}` 指本 SKILL.md 所在的目录(即 WeWrite 的根目录)",
"本文档中 `{baseDir}` 指本 SKILL.md 所在的目录(即 WeWrite 的根目录)",
)
return body
def split_frontmatter(text: str) -> tuple[str, str]:
"""Split YAML frontmatter from body. Returns (frontmatter, body)."""
if not text.startswith("---"):
return "", text
end = text.find("\n---", 3)
if end == -1:
return "", text
# +4 to skip the closing "---\n"
fm = text[3:end].strip()
body = text[end + 4:] # skip "\n---"
return fm, body
def build(output_dir: Path):
skill_src = REPO_ROOT / "SKILL.md"
text = skill_src.read_text(encoding="utf-8")
fm, body = split_frontmatter(text)
fm = transform_frontmatter(fm)
body = transform_body(body)
out_skill = output_dir / "SKILL.md"
output_dir.mkdir(parents=True, exist_ok=True)
out_skill.write_text(f"---\n{fm}\n---{body}", encoding="utf-8")
print(f" SKILL.md → {out_skill}")
# Copy supporting directories
for d in COPY_DIRS:
src = REPO_ROOT / d
dst = output_dir / d
if src.is_dir():
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(src, dst)
print(f" {d}/ → {dst}")
# Copy supporting files
for f in COPY_FILES:
src = REPO_ROOT / f
if src.is_file():
shutil.copy2(src, output_dir / f)
print(f" {f}{output_dir / f}")
print(f"\nDone. OpenClaw skill at: {output_dir}")
def main():
parser = argparse.ArgumentParser(description="Build OpenClaw-compatible WeWrite skill")
parser.add_argument(
"-o", "--output",
default=str(REPO_ROOT / "dist" / "openclaw"),
help="Output directory (default: dist/openclaw/)",
)
args = parser.parse_args()
build(Path(args.output))
if __name__ == "__main__":
main()