新增 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:
parent
7c2dc4adc9
commit
e1a0d6ef47
53 changed files with 8775 additions and 1 deletions
38
.github/workflows/build-openclaw.yml
vendored
Normal file
38
.github/workflows/build-openclaw.yml
vendored
Normal 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
3
.gitignore
vendored
|
|
@ -27,7 +27,8 @@ __pycache__/
|
|||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
dist/
|
||||
dist/*
|
||||
!dist/openclaw/
|
||||
build/
|
||||
|
||||
# IDE
|
||||
|
|
|
|||
298
dist/openclaw/SKILL.md
vendored
Normal file
298
dist/openclaw/SKILL.md
vendored
Normal 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
28
dist/openclaw/config.example.yaml
vendored
Normal 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"
|
||||
45
dist/openclaw/personas/cold-analyst.yaml
vendored
Normal file
45
dist/openclaw/personas/cold-analyst.yaml
vendored
Normal 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:
|
||||
- "口语化表达和网络用语"
|
||||
- "强烈的情感判断"
|
||||
- "无来源的数据引用"
|
||||
- "过度简化的类比(保持分析精度)"
|
||||
43
dist/openclaw/personas/industry-observer.yaml
vendored
Normal file
43
dist/openclaw/personas/industry-observer.yaml
vendored
Normal 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动了等网络用语)"
|
||||
- "过多感性表达"
|
||||
- "无来源的断言"
|
||||
- "报告式堆砌(要有分析不只有数据)"
|
||||
51
dist/openclaw/personas/midnight-friend.yaml
vendored
Normal file
51
dist/openclaw/personas/midnight-friend.yaml
vendored
Normal 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:
|
||||
- "总结性收尾(让我们拭目以待/未来可期)"
|
||||
- "平铺直叙的情绪(全文同一温度)"
|
||||
- "报告式数据罗列(不包裹在个人反应里的裸数据)"
|
||||
- "每段首句承接上段"
|
||||
42
dist/openclaw/personas/sharp-journalist.yaml
vendored
Normal file
42
dist/openclaw/personas/sharp-journalist.yaml
vendored
Normal 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
44
dist/openclaw/personas/warm-editor.yaml
vendored
Normal 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:
|
||||
- "冷硬的专业术语(用比喻替代)"
|
||||
- "攻击性或讽刺性语言"
|
||||
- "密集的数据堆砌(数据要稀释在故事里)"
|
||||
- "急促的节奏(保持舒缓)"
|
||||
19
dist/openclaw/references/effect-review.md
vendored
Normal file
19
dist/openclaw/references/effect-review.md
vendored
Normal 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
192
dist/openclaw/references/frameworks.md
vendored
Normal 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
51
dist/openclaw/references/learn-edits.md
vendored
Normal 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
86
dist/openclaw/references/onboard.md
vendored
Normal 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
62
dist/openclaw/references/seo-rules.md
vendored
Normal 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 要具体(不要"欢迎留言",要"你觉得哪个方案更靠谱?评论区聊聊")
|
||||
60
dist/openclaw/references/style-template.md
vendored
Normal file
60
dist/openclaw/references/style-template.md
vendored
Normal 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 风(蓝色链接,等宽代码块,开发者友好) |
|
||||
106
dist/openclaw/references/topic-selection.md
vendored
Normal file
106
dist/openclaw/references/topic-selection.md
vendored
Normal 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 的数据(如果有),不要纯靠猜
|
||||
152
dist/openclaw/references/visual-prompts.md
vendored
Normal file
152
dist/openclaw/references/visual-prompts.md
vendored
Normal 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"
|
||||
```
|
||||
443
dist/openclaw/references/wechat-constraints.md
vendored
Normal file
443
dist/openclaw/references/wechat-constraints.md
vendored
Normal 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技巧
|
||||
286
dist/openclaw/references/writing-guide.md
vendored
Normal file
286
dist/openclaw/references/writing-guide.md
vendored
Normal 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 用于论点内的小节(可选,不要滥用)
|
||||
- 图片用相对路径:``
|
||||
- 不要用 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
7
dist/openclaw/requirements.txt
vendored
Normal 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
132
dist/openclaw/scripts/build_openclaw.py
vendored
Normal 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
198
dist/openclaw/scripts/build_playbook.py
vendored
Normal 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
180
dist/openclaw/scripts/fetch_hotspots.py
vendored
Normal 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
179
dist/openclaw/scripts/fetch_stats.py
vendored
Normal 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
299
dist/openclaw/scripts/humanness_score.py
vendored
Normal 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
274
dist/openclaw/scripts/learn_edits.py
vendored
Normal 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
149
dist/openclaw/scripts/optimize_loop.py
vendored
Normal 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
119
dist/openclaw/scripts/seo_keywords.py
vendored
Normal 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
49
dist/openclaw/style.example.yaml
vendored
Normal 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
419
dist/openclaw/toolkit/cli.py
vendored
Normal 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
548
dist/openclaw/toolkit/converter.py
vendored
Normal 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 CJK↔Latin 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
318
dist/openclaw/toolkit/image_gen.py
vendored
Normal 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
138
dist/openclaw/toolkit/publisher.py
vendored
Normal 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
197
dist/openclaw/toolkit/theme.py
vendored
Normal 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
|
||||
207
dist/openclaw/toolkit/themes/bauhaus.yaml
vendored
Normal file
207
dist/openclaw/toolkit/themes/bauhaus.yaml
vendored
Normal 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;
|
||||
}
|
||||
198
dist/openclaw/toolkit/themes/bold-green.yaml
vendored
Normal file
198
dist/openclaw/toolkit/themes/bold-green.yaml
vendored
Normal 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;
|
||||
}
|
||||
197
dist/openclaw/toolkit/themes/bold-navy.yaml
vendored
Normal file
197
dist/openclaw/toolkit/themes/bold-navy.yaml
vendored
Normal 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;
|
||||
}
|
||||
199
dist/openclaw/toolkit/themes/bytedance.yaml
vendored
Normal file
199
dist/openclaw/toolkit/themes/bytedance.yaml
vendored
Normal 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;
|
||||
}
|
||||
198
dist/openclaw/toolkit/themes/elegant-rose.yaml
vendored
Normal file
198
dist/openclaw/toolkit/themes/elegant-rose.yaml
vendored
Normal 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;
|
||||
}
|
||||
197
dist/openclaw/toolkit/themes/focus-red.yaml
vendored
Normal file
197
dist/openclaw/toolkit/themes/focus-red.yaml
vendored
Normal 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
198
dist/openclaw/toolkit/themes/github.yaml
vendored
Normal 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
204
dist/openclaw/toolkit/themes/ink.yaml
vendored
Normal 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;
|
||||
}
|
||||
197
dist/openclaw/toolkit/themes/midnight.yaml
vendored
Normal file
197
dist/openclaw/toolkit/themes/midnight.yaml
vendored
Normal 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;
|
||||
}
|
||||
202
dist/openclaw/toolkit/themes/minimal-gold.yaml
vendored
Normal file
202
dist/openclaw/toolkit/themes/minimal-gold.yaml
vendored
Normal 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%;
|
||||
}
|
||||
195
dist/openclaw/toolkit/themes/minimal.yaml
vendored
Normal file
195
dist/openclaw/toolkit/themes/minimal.yaml
vendored
Normal 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;
|
||||
}
|
||||
206
dist/openclaw/toolkit/themes/newspaper.yaml
vendored
Normal file
206
dist/openclaw/toolkit/themes/newspaper.yaml
vendored
Normal 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%;
|
||||
}
|
||||
197
dist/openclaw/toolkit/themes/professional-clean.yaml
vendored
Normal file
197
dist/openclaw/toolkit/themes/professional-clean.yaml
vendored
Normal 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
198
dist/openclaw/toolkit/themes/sspai.yaml
vendored
Normal 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;
|
||||
}
|
||||
205
dist/openclaw/toolkit/themes/tech-modern.yaml
vendored
Normal file
205
dist/openclaw/toolkit/themes/tech-modern.yaml
vendored
Normal 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;
|
||||
}
|
||||
197
dist/openclaw/toolkit/themes/warm-editorial.yaml
vendored
Normal file
197
dist/openclaw/toolkit/themes/warm-editorial.yaml
vendored
Normal 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
115
dist/openclaw/toolkit/wechat_api.py
vendored
Normal 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"]
|
||||
79
dist/openclaw/writing-config.example.yaml
vendored
Normal file
79
dist/openclaw/writing-config.example.yaml
vendored
Normal 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
132
scripts/build_openclaw.py
Normal 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()
|
||||
Loading…
Reference in a new issue