Initial release — 公众号文章全流程 AI Skill

热点抓取 → 选题 → 框架 → 写作 → SEO → 视觉AI → 排版 → 微信草稿箱,
一句话触发完整流程。适用于 Claude Code skill 格式。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
wangzhuc 2026-03-26 22:16:18 +08:00
commit 1ab34fa450
32 changed files with 4599 additions and 0 deletions

27
.gitignore vendored Normal file
View file

@ -0,0 +1,27 @@
# Credentials
config.yaml
# Generated output (keep directory structure)
output/**/
!output/.gitkeep
# macOS
.DS_Store
# Python
__pycache__/
*.pyc
*.pyo
*.egg-info/
dist/
build/
# IDE
.vscode/
.idea/
# Client data (demo is tracked as template)
clients/*/corpus/
clients/*/lessons/
clients/*/playbook.md
!clients/demo/

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 OpenClaw
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

158
README.md Normal file
View file

@ -0,0 +1,158 @@
# media-agent
公众号文章全流程 AI Skill —— 从热点抓取到草稿箱推送,一句话搞定。
适用于 [Claude Code](https://claude.ai/code) 的 skill 格式。安装后对 Claude 说「用 demo 的配置写一篇公众号文章」即可触发完整流程。
## 功能
| 步骤 | 能力 | 脚本/模块 |
|------|------|-----------|
| 热点抓取 | 微博 + 头条 + 百度实时热搜 | `scripts/fetch_hotspots.py` |
| SEO 评分 | 百度 + 360 搜索建议量化 | `scripts/seo_keywords.py` |
| 选题生成 | 10 选题 × 3 维度评分 | LLM + `references/topic-selection.md` |
| 框架生成 | 5 套差异化写作骨架 | LLM + `references/frameworks.md` |
| 文章写作 | 去 AI 痕迹 + 客户风格适配 | LLM + `references/writing-guide.md` |
| SEO 优化 | 标题 / 摘要 / 关键词 / 标签 | LLM + `references/seo-rules.md` |
| 视觉 AI | 封面 3 创意 + 内文 3-6 配图 | `toolkit/image_gen.py`doubao / OpenAI |
| 排版发布 | Markdown → 微信内联样式 HTML → 草稿箱 | `toolkit/cli.py` |
| 效果复盘 | 微信数据分析 API 回填阅读数据 | `scripts/fetch_stats.py` |
| Playbook 学习 | 从人工修改中提取风格规律 | `scripts/learn_edits.py` |
## 安装
### 作为 Claude Code Skill
```bash
# 方式 1直接引用目录
# 在你的 Claude Code 设置中添加 skill 路径
# 方式 2复制到 skills 目录
cp -r media-agent ~/.claude/skills/media-agent
```
### 安装 Python 依赖
```bash
cd media-agent
pip install -r requirements.txt
```
### 配置
```bash
cp config.example.yaml config.yaml
```
编辑 `config.yaml`,填入:
- **微信公众号** `appid` / `secret`(发布和数据统计需要)
- **图片生成 API key**doubao-seedream 或 OpenAI DALL-E
## 目录结构
```
media-agent/
├── SKILL.md # Skill 主文件Claude 读取并执行)
├── config.example.yaml # 配置模板
├── requirements.txt # Python 依赖
├── scripts/ # 数据采集脚本
│ ├── fetch_hotspots.py # 多平台热点抓取
│ ├── seo_keywords.py # SEO 关键词分析
│ ├── fetch_stats.py # 微信文章数据回填
│ ├── build_playbook.py # 从历史文章生成 Playbook
│ └── learn_edits.py # 学习人工修改
├── toolkit/ # Markdown→微信 工具链
│ ├── cli.py # CLI 入口preview / publish / themes
│ ├── converter.py # Markdown→内联样式 HTML
│ ├── theme.py # YAML 主题系统
│ ├── publisher.py # 微信草稿箱 API
│ ├── wechat_api.py # 微信 access_token / 图片上传
│ ├── image_gen.py # AI 图片生成(多 provider
│ └── themes/ # 4 套预置排版主题
├── references/ # Claude 按需读取的参考文档
│ ├── topic-selection.md # 选题评估规则
│ ├── frameworks.md # 5 种写作框架
│ ├── writing-guide.md # 写作规范 + 去 AI 痕迹
│ ├── seo-rules.md # 微信 SEO 规则
│ ├── visual-prompts.md # 视觉 AI 提示词规范
│ ├── wechat-constraints.md # 微信平台技术限制
│ └── style-template.md # 客户配置模板说明
├── clients/ # 客户配置(每个客户一个目录)
│ └── demo/ # 示例客户
│ ├── style.yaml # 风格配置
│ └── history.yaml # 发布历史
└── output/ # 生成的文章输出目录
```
## 客户配置
每个客户是 `clients/{name}/` 下的一个目录。核心配置文件是 `style.yaml`
```yaml
name: "客户名称"
industry: "行业"
topics:
- "方向1"
- "方向2"
tone: "写作风格描述"
theme: "professional-clean"
```
详见 `references/style-template.md` 或复制 `clients/demo/style.yaml` 修改。
## 图片生成
支持两种 provider通过 `config.yaml` 切换:
| Provider | 适用场景 | 配置 |
|----------|---------|------|
| `doubao` | 中文提示词效果好,国内访问快 | [火山引擎 Ark](https://console.volcengine.com/ark) API key |
| `openai` | DALL-E 3国际通用 | OpenAI API key |
CLI 独立使用:
```bash
python3 toolkit/image_gen.py --prompt "描述" --output cover.png --size cover
python3 toolkit/image_gen.py --prompt "描述" --output img.png --provider openai
```
## 排版主题
| 主题 | 风格 |
|------|------|
| `professional-clean` | 专业简洁(默认) |
| `tech-modern` | 科技风(蓝紫渐变) |
| `warm-editorial` | 暖色编辑风 |
| `minimal` | 极简黑白 |
预览主题:`python3 toolkit/cli.py themes`
独立排版:`python3 toolkit/cli.py preview article.md --theme tech-modern`
## Toolkit 独立使用
即使不用 Claude Codetoolkit 也可以独立使用:
```bash
# 预览 Markdown → 微信 HTML
python3 toolkit/cli.py preview article.md --theme professional-clean
# 发布到微信草稿箱
python3 toolkit/cli.py publish article.md --cover cover.png --title "文章标题"
# 抓热点
python3 scripts/fetch_hotspots.py --limit 20
# SEO 分析
python3 scripts/seo_keywords.py --json "AI大模型" "科技股"
```
## License
MIT

359
SKILL.md Normal file
View file

@ -0,0 +1,359 @@
---
name: media-agent
description: |
微信公众号内容全流程助手:热点抓取 → 选题 → 框架 → 写作 → SEO/去AI痕迹 → 视觉AI → 排版推送草稿箱。
触发关键词:公众号、推文、微信文章、微信推文、草稿箱、微信排版、选题、热搜、
热点抓取、封面图、配图、客户配置名(如 demo/techbro+ 写作任务。
也覆盖markdown 转微信格式、学习用户改稿风格、文章数据复盘、新建客户配置。
不应被通用的"写文章"、blog、邮件、PPT、抖音/短视频、网站 SEO 触发——
需要有公众号/微信等明确上下文。
---
# Media Agent — 公众号文章全流程
## 快速理解
你是一个公众号内容编辑 Agent。用户给你一个客户名你完成从热点抓取到草稿箱推送的全部工作。
**默认全自动**——不要中途停下来问用户选哪个选题、选哪个框架。自动选最优的,一口气跑完全流程。只在出错时才停下来。
**交互模式**——如果用户说"交互模式"、"我要自己选"、"让我看看选题",才在选题/框架/配图处暂停等确认。
每一步都有降级方案,不要因为某一步失败就停下来。
## 执行流程
### Step 1: 确定客户
从用户消息中提取客户名称,读取配置:
```
读取: {skill_dir}/clients/{client}/style.yaml
```
如果客户目录不存在,告诉用户:
- 参考 `{skill_dir}/references/style-template.md` 创建配置
- 或复制 `clients/demo/style.yaml` 作为模板
从 style.yaml 中提取:`topics`、`tone`、`voice`、`blacklist`、`theme`、`cover_style`、`author`、`content_style`。
如果用户直接给了选题(如"写一篇关于 AI Agent 的公众号文章"),跳过 Step 2-3直接进入 Step 3.5。
---
### Step 2: 热点抓取
```bash
python3 {skill_dir}/scripts/fetch_hotspots.py --limit 30
```
脚本返回 JSON 到 stdout包含多平台热点微博、头条、百度
为每条热点标注所属领域和可创作性评分1-10
**降级**:如果脚本报错或返回空列表,用 WebSearch 搜索 "今日热点 {topics中的第一个垂类}"。
---
### Step 2.5: 历史读取 + SEO 数据
```
读取: {skill_dir}/clients/{client}/history.yaml
```
提取已发布文章的 topic_keywords 列表,用于 Step 3 去重。
如果 history.yaml 中有带 stats 的文章,提取表现最好的文章特征(框架类型、标题风格),作为偏好参考。
然后对热点中的关键词做 SEO 评分:
```bash
python3 {skill_dir}/scripts/seo_keywords.py --json {从热点标题中提取的3-5个关键词}
```
脚本返回每个关键词的 SEO 评分0-10和相关关键词用于 Step 3 的 SEO 友好度评估。
---
### Step 3: 选题生成
```
读取: {skill_dir}/references/topic-selection.md
```
按评估规则生成 10 个选题每个含标题、评分、点击率潜力、SEO 友好度、推荐框架。
**去重**:对比 history.yaml 中的 topic_keywords如果某个选题的核心关键词在最近 7 天内已写过,降低其评分或标注"近期已覆盖"。
**SEO 数据化**:用 Step 2.5 的 seo_keywords.py 输出替代纯 LLM 猜测。SEO 友好度直接引用脚本返回的 seo_score。
- **自动模式(默认)**:直接选综合评分最高的,继续。
- **交互模式**:输出 10 个选题,等用户选择。
---
### Step 3.5: 框架选择
```
读取: {skill_dir}/references/frameworks.md
```
为选定的选题生成 5 套框架(痛点型/故事型/清单型/对比型/热点解读型),每套含开头策略、段落大纲、金句预埋、结尾引导、推荐指数。
- **自动模式(默认)**:直接选推荐指数最高的框架,继续。
- **交互模式**:输出 5 套框架,等用户选择。
---
### Step 4: 文章写作
```
读取: {skill_dir}/references/writing-guide.md
读取: {skill_dir}/clients/{client}/playbook.md如果存在
```
按选定框架 + writing-guide.md 规范写文章:
- H1 标题20-28 字converter 自动提取为微信标题)
- 字数 1500-2500
- 按框架大纲组织结构,在金句落点放精炼总结句
- 不插配图占位符Step 6 自动分析插入)
- 风格遵循 style.yaml 的 tone、voice、content_style
- 避开 blacklist
**Playbook 优先**:如果 playbook.md 存在,其中的规则优先于 writing-guide.md 的通用规则。比如 playbook 说"从不用问句结尾"而 writing-guide 建议用反问句,以 playbook 为准。playbook 是客户的个性writing-guide 是通用底线。
保存到 `{skill_dir}/output/{client}/{date}-{slug}.md`
---
### Step 5: SEO 优化 + 去AI痕迹
```
读取: {skill_dir}/references/seo-rules.md
读取: {skill_dir}/references/writing-guide.md去AI痕迹部分
```
对初稿执行:
1. 生成 3 个备选标题20-28 字),标注策略
2. 优化关键词密度
3. 去AI痕迹
4. 生成摘要(≤ 54 个中文字)
5. 推荐 5 个精准标签
6. 完读率优化
覆盖保存终稿。自动模式下选评分最高的标题作为最终标题。
---
### Step 6: 视觉AI
```
读取: {skill_dir}/references/visual-prompts.md
```
#### 6a. 分析文章 + 生成提示词
读取终稿,分析结构:
- 提取 H2 标题和各论点段落
- 逐个论点判断是否需要配图(数据/场景/转折处优先,纯观点段可不配)
- 确定配图位置和画面描述
- 约束:总数 3-6 张间隔≥300字不在开头和 CTA 处配图
生成封面 3 组创意(直觉冲击/氛围渲染/信息图表)+ 内文配图提示词。
- **自动模式(默认)**:直接用创意 A 作为封面,全部配图直接生成,不停顿。
- **交互模式**:输出方案,等用户确认或调整。
将占位符 `![配图:场景描述](placeholder)` 插入 Markdown。
#### 6b. 自动生图
```bash
# 封面2.35:1 微信封面比例)
python3 {skill_dir}/toolkit/image_gen.py \
--prompt "{封面提示词}" \
--output {skill_dir}/output/{client}/{date}-cover.png \
--size cover
# 内文配图16:9 横版)
python3 {skill_dir}/toolkit/image_gen.py \
--prompt "{配图提示词}" \
--output {skill_dir}/output/{client}/{date}-img{序号}.png \
--size article
# 可通过 --provider 覆盖默认 providerdoubao/openai
```
生成后替换 Markdown 中的 placeholder 为实际图片路径。
**降级**:如果 image_gen.py 报错,输出提示词供用户自行生成,继续后续步骤。
---
### Step 7: 排版 + 推送草稿
```bash
python3 {skill_dir}/toolkit/cli.py publish {markdown_path} \
--cover {cover_path} \
--theme {style.yaml 的 theme} \
--title "{最终标题}"
```
如果有 cover 就加 `--cover`,没有就不加。
**降级**:如果 publish 失败,改用 preview
```bash
python3 {skill_dir}/toolkit/cli.py preview {markdown_path} \
--theme {theme} --no-open -o {output_dir}/{slug}.html
```
告知用户本地 HTML 路径。
---
### Step 7.5: 写入历史
发布成功后,向 `{skill_dir}/clients/{client}/history.yaml` 追加一条记录:
```yaml
- date: "{今天日期}"
title: "{最终标题}"
topic_source: "热点抓取" # 或 "用户指定"
topic_keywords: ["{关键词1}", "{关键词2}"]
framework: "{使用的框架类型}"
word_count: {字数}
media_id: "{media_id}"
stats: null # 由 fetch_stats.py 后续回填
```
这条记录会被下次运行的 Step 2.5 读取,用于选题去重和偏好分析。
---
### Step 8: 回复用户
**成功**
- 最终标题 + 2 个备选标题
- 摘要
- 5 个推荐标签
- media_id
- 提醒:请到公众号后台草稿箱检查并发布
**部分成功**
- 列出每步状态(成功/跳过/失败)
- 附上本地文件路径
- 说明哪些需要用户手动完成
**用户可以继续要求**
- "帮我润色/缩写/扩写/换语气" → 编辑文章
- "封面换暖色调" → 修改提示词,重新生图
- "第 3 张配图不要了" → 调整 Markdown
- "用框架 B 重写" → 回到 Step 4
- "换一个选题" → 回到 Step 3 展示选题列表
- "看看文章数据" / "效果怎么样" → 执行效果复盘(见下方)
---
## 效果复盘
当用户问"文章数据怎么样"、"效果复盘"、"看看表现"时:
```bash
python3 {skill_dir}/scripts/fetch_stats.py --client {client} --days 7
```
脚本会:
1. 调微信数据分析 API 拉取最近 7 天的文章阅读数据
2. 匹配 history.yaml 中的文章记录
3. 回填 stats 字段(阅读量、分享量、点赞量、阅读率)
回填后,分析数据并给出建议:
- 哪篇文章表现最好?为什么?(标题策略?选题热度?框架类型?)
- 哪篇表现不好?可能的原因?
- 对后续选题/标题/框架的调整建议
这些分析会影响下次运行时 Step 2.5 的偏好参考。
---
## 客户 Onboard
当用户说"新建客户"、"导入历史文章"、"建 playbook"时:
### 1. 创建客户目录
```
{skill_dir}/clients/{client}/
├── style.yaml # 复制 demo 模板,让用户填写
├── corpus/ # 用户放入历史推文 .md 文件
├── history.yaml # 空初始化
└── lessons/ # 空目录
```
### 2. 生成 Playbook
用户将历史推文放入 `corpus/` 后:
```bash
python3 {skill_dir}/scripts/build_playbook.py --client {client}
```
脚本输出语料统计 + 分析指令。按指令逐批阅读文章,提取风格特征,生成 `playbook.md`
建议至少 20 篇历史文章50+ 篇效果更好。
---
## 学习人工修改
当用户说"我改了,学习一下"、"学习我的修改"时:
### 1. 获取 draft 和 final
- draft`output/{client}/` 下最新的 .md 文件
- final用户提供修改后的版本粘贴或指定文件路径
### 2. 运行 diff 分析
```bash
python3 {skill_dir}/scripts/learn_edits.py --client {client} --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 --client {client} --summarize
```
读取所有 lessons找出反复出现的 pattern≥2 次),将其固化到 `playbook.md` 的对应章节。
---
## 错误处理
不要因为任何一步失败就停止整个流程。
| 步骤 | 降级 |
|------|------|
| 热点抓取失败 | WebSearch 替代 |
| 选题为空 | 请用户手动给选题 |
| SEO 关键词查询失败 | 回退到 LLM 判断 |
| 封面生成失败 | 输出提示词,用户自行生成 |
| 推送失败 | 生成本地 HTML手动操作 |
| 历史写入失败 | 警告但不阻断流程 |
| 效果数据拉取失败 | 告知用户可能需要等 24h微信数据有延迟 |
| Playbook 不存在 | 正常——用 writing-guide.md 通用规则 |

19
clients/demo/history.yaml Normal file
View file

@ -0,0 +1,19 @@
# 文章发布历史 — Demo科技
# 由 SKILL.md Step 7 自动维护,每次发布后追加记录
# 选题时读取,避免重复 + 分析偏好
articles: []
# 每条记录格式:
# - date: "2026-03-23"
# title: "文章标题"
# topic_source: "热点抓取" # 热点抓取 / 用户指定
# topic_keywords: ["AI", "科技股"]
# framework: "热点解读型"
# word_count: 2000
# media_id: "xxx"
# stats: # 由 fetch_stats.py 回填
# read_count: 0
# share_count: 0
# like_count: 0
# read_rate: 0 # 阅读率 = 阅读数 / 送达数

42
clients/demo/style.yaml Normal file
View file

@ -0,0 +1,42 @@
# 客户配置 — Demo 科技媒体
name: "Demo科技"
industry: "科技/互联网"
target_audience: "25-40岁互联网从业者、科技爱好者"
# 内容方向
topics:
- AI/人工智能
- 产品设计
- 创业/商业模式
- 效率工具
# 写作风格
tone: "专业但不学术,有观点但不偏激,偶尔幽默"
voice: "第一人称,像一个懂行的朋友在分享见解"
word_count: "1500-2500"
# 内容风格(干货/故事/情绪/热点/测评)
# 影响选题偏好和框架推荐
content_style: "干货"
# 禁忌
blacklist:
words: ["震惊", "必看", "不转不是中国人", "赶紧收藏"]
topics: ["政治敏感", "宗教", "色情", "赌博"]
# 参考账号风格
reference_accounts:
- "36氪"
- "虎嗅"
- "少数派"
# 排版
theme: "professional-clean"
# 封面
cover_style: "简洁科技感,蓝色调,扁平化设计"
# cover_template: "" # 设置后跳过 AI 生成,直接使用该文件
# 署名
author: "Demo编辑部"

28
config.example.yaml Normal file
View file

@ -0,0 +1,28 @@
# media-agent 配置
# 复制为 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"

26
evals/evals.json Normal file
View file

@ -0,0 +1,26 @@
{
"skill_name": "media-agent",
"evals": [
{
"id": 0,
"name": "topic-writing",
"prompt": "用 demo 的配置,写一篇关于 AI Agent 正在取代传统 SaaS 的公众号文章",
"expected_output": "一篇 1500-2500 字的公众号文章 Markdown包含 H1 标题、H2 结构、去 AI 痕迹、符合 demo 客户的 style.yaml 风格",
"files": []
},
{
"id": 1,
"name": "markdown-convert",
"prompt": "把 clients/demo/ 下的 style.yaml 看一下,然后帮我用 professional-clean 主题把这段 markdown 转成微信公众号格式的 HTML 并预览:\n\n# AI时代的效率革命\n\n## 为什么传统工具不够用了\n\n说实话2026年还在用老一套工具的人效率已经被甩开一个身位了。\n\n不是危言耸听。上周我帮一个朋友梳理他的工作流发现他每天花 3 小时在重复性的文档整理上。\n\n## AI 工具的正确打开方式\n\n关键不是「用 AI」而是「让 AI 做它擅长的事」。\n\n比如数据清洗、格式转换、初稿生成——这些重复性高、规则明确的任务AI 做得比人快 10 倍。\n\n## 写在最后\n\n工具在进化人也得跟上。",
"expected_output": "一个微信兼容的内联样式 HTML 文件,正确提取 H1 为标题H2 有主题样式,可在浏览器中预览",
"files": []
},
{
"id": 2,
"name": "client-onboard",
"prompt": "帮我新建一个叫 techbro 的客户,做科技自媒体,面向程序员群体,风格偏吐槽幽默,参考少数派和 V2EX 的调性",
"expected_output": "在 clients/techbro/ 下创建 style.yaml含 topics/tone/voice/blacklist 等)和空的 history.yaml",
"files": []
}
]
}

0
output/.gitkeep Normal file
View file

192
references/frameworks.md Normal file
View file

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

62
references/seo-rules.md Normal file
View file

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

View file

@ -0,0 +1,43 @@
# 如何创建客户配置
## 快速开始
1. 复制 `clients/demo/style.yaml``clients/{客户名}/style.yaml`
2. 修改配置项
3. 对 Agent 说:「用 {客户名} 的配置写一篇公众号文章」
## 必填字段
```yaml
name: "客户名称"
industry: "行业"
topics: # 内容方向(列表)
- "方向1"
- "方向2"
tone: "写作风格描述"
theme: "professional-clean" # 排版主题
```
## 可选字段
```yaml
target_audience: "目标受众描述"
voice: "写作人称和语感"
word_count: "1500-2500"
blacklist:
words: ["禁忌词1", "禁忌词2"]
topics: ["禁忌话题1"]
reference_accounts: ["参考账号1", "参考账号2"]
cover_style: "封面风格描述"
cover_template: "/path/to/cover.png" # 设置后跳过 AI 生成封面
author: "署名"
```
## 可用排版主题
| 主题 | 说明 |
|------|------|
| professional-clean | 专业简洁(默认,适合大部分企业) |
| tech-modern | 科技风(蓝紫渐变,适合技术/产品类) |
| warm-editorial | 暖色编辑风(适合生活/文化类) |
| minimal | 极简黑白(适合文学/严肃内容) |

View file

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

View file

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

View file

@ -0,0 +1,426 @@
# 微信公众号平台限制说明
本文档详细说明微信公众号编辑器的技术限制,以及本工具如何应对这些限制。
---
## 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. 参考资源
### 官方文档
- [微信公众平台 - 帮助中心](https://kf.qq.com/product/weixinmp.html)
- [微信公众号编辑器使用说明](https://mp.weixin.qq.com)
### 推荐工具
- **图床服务**: 阿里云OSS、七牛云、GitHub图床
- **浏览器插件**: 微信编辑器增强插件
- **Markdown编辑器**: Typora、VS Code、Obsidian
### 社区资源
- 搜索"微信公众号排版"获取更多技巧
- 参考优秀公众号的排版样式
- 使用浏览器"审查元素"学习别人的CSS技巧

112
references/writing-guide.md Normal file
View file

@ -0,0 +1,112 @@
# 写作规范
## 你的角色
你是这个公众号的主笔。你写的东西要像一个真人编辑写的——有观点、有个性、有瑕疵感。读者点开文章,应该觉得"这人挺懂的",而不是"这是 AI 写的"。
**规则优先级**:如果客户有 `playbook.md`其中的规则覆盖本文件的通用规则。playbook 是客户的个性化风格,本文件是通用底线。没有 playbook 时,完全按本文件执行。
## 文章结构
写之前,先读 `references/frameworks.md`,根据用户选定的框架来组织文章。
如果用户没选框架,根据选题特征和 content_style 自动选最合适的。默认推荐顺序:
- 干货型账号 → 痛点型或清单型
- 故事型账号 → 故事型
- 情绪型账号 → 故事型或痛点型
- 热点型账号 → 热点解读型
- 测评型账号 → 对比型或清单型
不管用哪个框架,以下规则始终适用:
**关于 H1**:必须写 H1 标题20-28 个中文字。media-agent 的 converter 会自动把 H1 提取为微信的标题字段,并从正文 HTML 中移除。所以 Markdown 里写 H1微信里看到的是独立标题栏 + 从 H2 开始的正文。
**关于金句**:框架中标注了"金句落点"的位置在那里放一句精炼的总结句。好的金句特征≤20字、有观点、能独立传播读者截图发朋友圈的那种
**关于配图**:写作时不要插入配图占位符。专心写内容,配图由 Step 6 视觉AI模块在终稿完成后自动分析插入。
## 去AI痕迹
这是最重要的部分。AI 写的文章有非常明显的"味道",读者一眼就能看出来。你需要系统性地消除这些痕迹。
### 必须删除的词汇
这些词一出现,读者就知道是 AI 写的:
**连接词**:首先、其次、再者、最后、总之、综上所述、总而言之、此外、另外、与此同时
**AI 惯用语**:作为一个、让我们、值得注意的是、需要指出的是、不可否认、毋庸置疑、众所周知
**空洞形容**:非常重要、至关重要、不言而喻、具有重要意义、发挥着重要作用
### 必须添加的元素
让文章读起来像真人写的:
**口语化表达**:说实话、讲真、你猜怎么着、我跟你说、坦白讲、怎么说呢
**个人观点标记**:我觉得、我的看法是、以我的经验、据我观察、我一直认为
**不完美感**
- 偶尔用一个不太精确但更生动的比喻
- 偶尔自嘲或开个小玩笑
- 偶尔用反问句打断叙述节奏
### 段落节奏
AI 文章最明显的特征是"每段都差不多长"。你需要刻意打破这个节奏:
- **禁止**:每段都是 3-4 句话的匀称结构
- **要求**
- 穿插 1 句话的短段落(语气强调、转折、吐槽)
- 偶尔 2-3 个短句连续排列,制造节奏感
- 长段落不超过 150 字
- 短段落和长段落交替出现
**好的节奏示例**
```
一段 80 字的正常段落,说明论点。
但是。
这里其实有个问题很多人没注意到。(短段落,制造悬念)
接下来是一段 120 字的段落,展开说明那个问题是什么、
为什么重要、有什么数据支撑。这段稍微长一点,
因为需要把事情说清楚。
讲真,我第一次看到这个数据的时候也吓了一跳。(口语化短段落)
```
## 字数控制
- 目标1500-2500 字
- 最少 1200 字,最多 3000 字
- 如果写完发现不到 1200 字,说明论点展开不够,需要补充案例或数据
- 如果超过 3000 字,说明论点太散,需要砍掉最弱的一个
## Markdown 格式要求
- H1 写标题converter 自动提取)
- H2 写核心论点
- H3 用于论点内的小节(可选,不要滥用)
- 图片用相对路径:`![描述](filename.jpg)`
- 不要用 HTML 标签,纯 Markdown
- 不要用 `---` 分割线(微信渲染效果不好)
## 写后编辑指令
文章写完后,用户可能要求修改。支持以下编辑指令:
**润色**:保持内容不变,优化用词和句式,让表达更精准、更有文采。
**缩写**:保留核心观点,删减案例和展开,压缩到用户指定字数。优先砍最弱的论点段落。
**扩写**:在现有框架上补充案例、数据或展开论述,扩展到用户指定字数。不要加新论点,深化现有论点。
**换语气**
- 正式 → 去掉口语化表达,补充数据引用,语言更严谨
- 口语 → 加入更多口语词、短句、反问,像在聊天
- 情绪 → 加强共鸣点,放大痛点/爽点,结尾更煽动
编辑后覆盖保存到同一文件。

7
requirements.txt Normal file
View file

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

199
scripts/build_playbook.py Normal file
View file

@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""
Build a client-specific writing playbook from historical articles.
Reads all .md files in clients/{client}/corpus/, analyzes writing patterns
in batches via LLM, and outputs a structured playbook.md.
Usage:
python3 build_playbook.py --client demo
python3 build_playbook.py --client demo --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(client: str) -> list[dict]:
"""Load all markdown files from corpus directory."""
corpus_dir = SKILL_DIR / "clients" / client / "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("--client", required=True, help="Client name")
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(args.client)
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(f"CORPUS ANALYSIS — {args.client}")
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()

167
scripts/fetch_hotspots.py Normal file
View file

@ -0,0 +1,167 @@
#!/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 for sorting (different scales across sources)
all_items.sort(key=lambda x: int(x.get("hot", 0) or 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()

180
scripts/fetch_stats.py Normal file
View file

@ -0,0 +1,180 @@
#!/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 --client demo
python3 fetch_stats.py --client demo --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" / "media-agent" / "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(client: str, stats_list: list[dict]):
"""Match stats to history.yaml entries and update."""
history_path = SKILL_DIR / "clients" / client / "history.yaml"
if not history_path.exists():
print(f"No history.yaml found for client: {client}")
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("--client", required=True, help="Client name")
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 client '{args.client}', 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(args.client, 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()

275
scripts/learn_edits.py Normal file
View file

@ -0,0 +1,275 @@
#!/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 clients/{client}/lessons/.
When 5+ lessons accumulate, outputs a prompt for the Agent to update playbook.md.
Usage:
python3 learn_edits.py --client demo --draft path/to/draft.md --final path/to/final.md
python3 learn_edits.py --client demo --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(client: str, diff_result: dict, draft_path: str, final_path: str):
"""Save diff data for Agent to analyze and write lessons."""
lessons_dir = SKILL_DIR / "clients" / client / "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(client: str) -> int:
"""Count existing lesson files."""
lessons_dir = SKILL_DIR / "clients" / client / "lessons"
if not lessons_dir.exists():
return 0
return len(list(lessons_dir.glob("*-diff*.yaml")))
def summarize_lessons(client: str):
"""Load all lessons and output for Agent to update playbook."""
lessons_dir = SKILL_DIR / "clients" / client / "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("--client", required=True, help="Client name")
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(args.client)
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(args.client, diff_result, args.draft, args.final)
print(f"\nDiff saved to: {diff_file}")
# Check if playbook update should be triggered
lesson_count = count_lessons(args.client)
print(f"Total lessons for {args.client}: {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 --client {args.client} --summarize")
print(f"2. Read current playbook: clients/{args.client}/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 client'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 clients/{args.client}/playbook.md.
""")
if __name__ == "__main__":
main()

119
scripts/seo_keywords.py Normal file
View file

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

187
toolkit/cli.py Normal file
View file

@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""
CLI entry point for media-agent.
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
# 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" / "media-agent" / "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 main():
parser = argparse.ArgumentParser(
prog="media-agent",
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")
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)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

242
toolkit/converter.py Normal file
View file

@ -0,0 +1,242 @@
"""
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)
# 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)
# Apply inline CSS from theme
html = self._apply_inline_styles(html)
# Apply WeChat compatibility fixes
html = self._apply_wechat_fixes(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)
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
toolkit/image_gen.py Normal file
View file

@ -0,0 +1,318 @@
#!/usr/bin/env python3
"""
AI image generation module for media-agent.
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" / "media-agent" / "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()

62
toolkit/publisher.py Normal file
View file

@ -0,0 +1,62 @@
import json
import requests
from dataclasses import dataclass
from typing import Optional
@dataclass
class DraftResult:
media_id: str
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"])

197
toolkit/theme.py Normal file
View file

@ -0,0 +1,197 @@
"""
Theme system for media-agent.
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

186
toolkit/themes/minimal.yaml Normal file
View file

@ -0,0 +1,186 @@
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"
base_css: |
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 16px;
line-height: 1.8;
color: #333333;
background: #ffffff;
max-width: 720px;
margin: 0 auto;
padding: 20px;
word-wrap: break-word;
}
h1 {
font-size: 26px;
font-weight: 700;
color: #1a1a1a;
margin: 32px 0 16px 0;
text-align: center;
line-height: 1.4;
}
h2 {
font-size: 22px;
font-weight: 700;
color: #1a1a1a;
margin: 28px 0 14px 0;
padding-bottom: 8px;
border-bottom: 1px solid #e0e0e0;
line-height: 1.4;
}
h3 {
font-size: 18px;
font-weight: 600;
color: #333333;
margin: 24px 0 12px 0;
line-height: 1.4;
}
h4 {
font-size: 16px;
font-weight: 600;
color: #333333;
margin: 20px 0 10px 0;
line-height: 1.4;
}
p {
font-size: 16px;
line-height: 1.8;
color: #333333;
margin: 12px 0;
}
strong {
font-weight: 700;
color: #1a1a1a;
}
em {
font-style: italic;
color: #333333;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: #f5f5f5;
color: #d73a49;
padding: 2px 6px;
border-radius: 4px;
}
pre {
background: #f5f5f5;
color: #333333;
padding: 16px;
border-radius: 4px;
overflow-x: auto;
margin: 16px 0;
line-height: 1.6;
border: 1px solid #e0e0e0;
}
pre code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: none;
color: #333333;
padding: 0;
border-radius: 0;
}
blockquote {
border-left: 3px solid #cccccc;
background: #f9f9f9;
margin: 16px 0;
padding: 12px 16px;
border-radius: 0 4px 4px 0;
color: #666666;
}
blockquote p {
margin: 8px 0;
color: #666666;
}
ul {
padding-left: 24px;
margin: 12px 0;
}
ol {
padding-left: 24px;
margin: 12px 0;
}
li {
font-size: 16px;
line-height: 1.8;
color: #333333;
margin: 6px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 15px;
}
thead {
background: rgba(0,0,0,0.03);
}
th {
background: rgba(0,0,0,0.03);
color: #333333;
font-weight: 600;
padding: 10px 14px;
text-align: left;
border: 1px solid #e0e0e0;
}
td {
padding: 10px 14px;
border: 1px solid #e0e0e0;
color: #333333;
}
tr {
background: #ffffff;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 24px auto;
border-radius: 4px;
}
a {
color: #333333;
text-decoration: underline;
}
hr {
border: none;
border-top: 1px solid #e0e0e0;
margin: 24px 0;
}

View file

@ -0,0 +1,188 @@
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"
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;
}

View file

@ -0,0 +1,196 @@
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"
base_css: |
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 16px;
line-height: 1.8;
color: #333333;
background: #ffffff;
max-width: 720px;
margin: 0 auto;
padding: 20px;
word-wrap: break-word;
}
h1 {
font-size: 26px;
font-weight: 700;
color: #1a1a1a;
margin: 32px 0 16px 0;
padding: 8px 0 8px 14px;
border-left: 5px solid #7c3aed;
line-height: 1.4;
}
h2 {
font-size: 22px;
font-weight: 700;
color: #1a1a1a;
margin: 28px 0 14px 0;
padding-bottom: 10px;
border-bottom: 2px solid transparent;
background-image: linear-gradient(90deg, #7c3aed 0%, #3b82f6 50%, transparent 50%);
background-size: 100% 2px;
background-position: 0 100%;
background-repeat: no-repeat;
line-height: 1.4;
}
h3 {
font-size: 18px;
font-weight: 600;
color: #333333;
margin: 24px 0 12px 0;
padding-left: 12px;
border-left: 3px solid #7c3aed;
line-height: 1.4;
}
h4 {
font-size: 16px;
font-weight: 600;
color: #333333;
margin: 20px 0 10px 0;
line-height: 1.4;
}
p {
font-size: 16px;
line-height: 1.8;
color: #333333;
margin: 12px 0;
}
strong {
font-weight: 700;
color: #7c3aed;
}
em {
font-style: italic;
color: #333333;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: #f5f5f5;
color: #e83e8c;
padding: 2px 6px;
border-radius: 4px;
}
pre {
background: #282c34;
color: #abb2bf;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
margin: 16px 0;
line-height: 1.6;
}
pre code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 14px;
background: none;
color: #abb2bf;
padding: 0;
border-radius: 0;
}
blockquote {
border-left: 4px solid #7c3aed;
background: #f8f5ff;
margin: 16px 0;
padding: 12px 16px;
border-radius: 0 8px 8px 0;
color: #333333;
}
blockquote p {
margin: 8px 0;
color: #333333;
}
ul {
padding-left: 24px;
margin: 12px 0;
list-style-type: disc;
color: #7c3aed;
}
ol {
padding-left: 24px;
margin: 12px 0;
}
li {
font-size: 16px;
line-height: 1.8;
color: #333333;
margin: 6px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 15px;
}
thead {
background: linear-gradient(135deg, #7c3aed 0%, #3b82f6 100%);
color: #ffffff;
}
th {
color: #ffffff;
font-weight: 600;
padding: 10px 14px;
text-align: left;
border: 1px solid #7c3aed;
}
td {
padding: 10px 14px;
border: 1px solid #e5e7eb;
color: #333333;
}
tr {
background: #ffffff;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 24px auto;
border-radius: 8px;
}
a {
color: #7c3aed;
text-decoration: none;
font-weight: 500;
}
hr {
border: none;
height: 2px;
background: linear-gradient(90deg, #7c3aed, #3b82f6, transparent);
margin: 24px 0;
}

View file

@ -0,0 +1,188 @@
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"
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
toolkit/wechat_api.py Normal file
View file

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