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:
commit
1ab34fa450
32 changed files with 4599 additions and 0 deletions
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
158
README.md
Normal 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 Code,toolkit 也可以独立使用:
|
||||
|
||||
```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
359
SKILL.md
Normal 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 作为封面,全部配图直接生成,不停顿。
|
||||
- **交互模式**:输出方案,等用户确认或调整。
|
||||
|
||||
将占位符 `` 插入 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 覆盖默认 provider(doubao/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
19
clients/demo/history.yaml
Normal 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
42
clients/demo/style.yaml
Normal 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
28
config.example.yaml
Normal 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
26
evals/evals.json
Normal 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
0
output/.gitkeep
Normal file
192
references/frameworks.md
Normal file
192
references/frameworks.md
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
# 写作框架库
|
||||
|
||||
## 你的任务
|
||||
|
||||
根据选题和客户风格,生成 5 套差异化写作框架供用户选择。每套框架是一个完整的文章骨架——不是写文章本身,而是告诉写作步骤"每一段写什么、怎么写"。
|
||||
|
||||
## 5 套框架类型
|
||||
|
||||
### 框架 A: 痛点型
|
||||
|
||||
适合:解决问题、提供方案的选题。干货型账号首选。
|
||||
|
||||
```
|
||||
结构:
|
||||
1. 开头(痛点共鸣)
|
||||
- 直接描述目标读者正在经历的痛点场景
|
||||
- 用"你是不是也..."或具体场景切入
|
||||
- 制造紧迫感:这个问题不解决会怎样
|
||||
|
||||
2. 痛点放大(H2)
|
||||
- 用数据或案例说明这个问题有多普遍
|
||||
- 分析为什么大多数人的做法是错的
|
||||
- 金句落点:一句话总结错误认知
|
||||
|
||||
3. 解决方案(H2)
|
||||
- 核心方法/工具/思路(不超过 3 个要点)
|
||||
- 每个要点配一个具体案例或操作步骤
|
||||
- 金句落点:一句话总结方法论
|
||||
|
||||
4. 实操验证(H2,可选)
|
||||
- 用一个完整案例走一遍解决流程
|
||||
- 或用前后对比展示效果
|
||||
|
||||
5. 结尾(行动引导)
|
||||
- 总结核心观点(一句话)
|
||||
- CTA:引导留言分享自己的痛点、或转发给同样有这个问题的朋友
|
||||
```
|
||||
|
||||
### 框架 B: 故事型
|
||||
|
||||
适合:人物、事件、趋势类选题。故事型/情绪型账号首选。
|
||||
|
||||
```
|
||||
结构:
|
||||
1. 开头(悬念钩子)
|
||||
- 抛出一个反直觉的结果或意外的场景
|
||||
- "谁也没想到..."、"所有人都以为...结果..."
|
||||
- 不要在开头剧透结论
|
||||
|
||||
2. 背景铺垫(H2)
|
||||
- 交代故事的时间、人物、起因
|
||||
- 控制在 200 字以内,快速过渡
|
||||
- 金句落点:一句话定义这个故事的核心矛盾
|
||||
|
||||
3. 转折与高潮(H2)
|
||||
- 事件的关键转折点
|
||||
- 用细节还原场景(对话、数字、画面)
|
||||
- 这是全文最花笔墨的地方
|
||||
|
||||
4. 深度解读(H2)
|
||||
- 从故事上升到规律/趋势/洞察
|
||||
- 这个故事对读者意味着什么
|
||||
- 金句落点:一句话总结你从这个故事中看到的本质
|
||||
|
||||
5. 结尾(情绪共振)
|
||||
- 回扣开头的悬念
|
||||
- CTA:引导读者分享"你身边有没有类似的故事"
|
||||
```
|
||||
|
||||
### 框架 C: 清单型
|
||||
|
||||
适合:盘点、推荐、方法论类选题。干货型/测评型账号首选。
|
||||
|
||||
```
|
||||
结构:
|
||||
1. 开头(价值承诺)
|
||||
- 直接告诉读者"看完这篇你能得到什么"
|
||||
- 用数字锚定预期:"5 个方法"、"3 个工具"、"7 个坑"
|
||||
- 简短说明为什么你有资格推荐(经验/测试/调研)
|
||||
|
||||
2. 清单项 1-N(每项一个 H2)
|
||||
- 每项结构统一:名称 → 一句话说明 → 具体案例或使用场景 → 适用人群
|
||||
- 项与项之间用不同长度,避免机械感
|
||||
- 每 2-3 项穿插一个金句或个人吐槽,打破节奏
|
||||
- 建议 5-7 项,不超过 10 项
|
||||
|
||||
3. 结尾(总结 + 彩蛋)
|
||||
- 一张表格或一句话总结所有清单项
|
||||
- 加一个"隐藏推荐"或"个人最爱"作为彩蛋
|
||||
- CTA:引导留言补充"你还知道哪些"
|
||||
```
|
||||
|
||||
### 框架 D: 对比型
|
||||
|
||||
适合:选择、决策、两个方案/观点的讨论。测评型/干货型账号首选。
|
||||
|
||||
```
|
||||
结构:
|
||||
1. 开头(选择困境)
|
||||
- 描述读者面临的"选 A 还是选 B"困境
|
||||
- 说明为什么这个选择很重要/很容易选错
|
||||
|
||||
2. A 方案深度分析(H2)
|
||||
- 优势(2-3 点,每点配案例)
|
||||
- 劣势(1-2 点,诚实说)
|
||||
- 最适合什么场景/什么人
|
||||
- 金句落点:一句话定义 A 的核心价值
|
||||
|
||||
3. B 方案深度分析(H2)
|
||||
- 同样的结构,与 A 形成对照
|
||||
- 金句落点:一句话定义 B 的核心价值
|
||||
|
||||
4. 对比总结(H2)
|
||||
- 用表格对比关键维度(3-5 个维度)
|
||||
- 明确给出"如果你是 X 情况选 A,如果是 Y 情况选 B"的结论
|
||||
- 不要和稀泥说"各有优劣"——读者要的是明确建议
|
||||
|
||||
5. 结尾(个人选择)
|
||||
- 说清楚"如果是我,我选X"以及为什么
|
||||
- CTA:引导投票或留言"你选哪个"
|
||||
```
|
||||
|
||||
### 框架 E: 热点解读型
|
||||
|
||||
适合:新闻、事件、行业动态的深度解读。热点型账号首选。
|
||||
|
||||
```
|
||||
结构:
|
||||
1. 开头(事件速览)
|
||||
- 2-3 句话说清楚发生了什么(5W1H 精简版)
|
||||
- 不要复制新闻原文,用自己的话重述
|
||||
- 用一个判断句结尾:"这件事比表面看起来复杂得多"
|
||||
|
||||
2. 表面信息(H2)
|
||||
- 大多数人看到的:媒体怎么报道的、网友怎么评论的
|
||||
- 简要梳理主流观点
|
||||
- 金句落点:指出主流观点的盲区
|
||||
|
||||
3. 深层分析(H2)
|
||||
- 你看到了什么别人没看到的
|
||||
- 这件事背后的利益链/技术逻辑/行业趋势
|
||||
- 用 1-2 个类比或历史事件做对照
|
||||
- 金句落点:一句话总结你的核心判断
|
||||
|
||||
4. 影响预判(H2)
|
||||
- 短期:接下来会怎样
|
||||
- 长期:对行业/普通人意味着什么
|
||||
- 说清楚不确定性:"如果 X 发生,则 Y;如果不发生,则 Z"
|
||||
|
||||
5. 结尾(读者行动建议)
|
||||
- 普通读者应该怎么应对/关注什么
|
||||
- CTA:引导关注后续进展、或留言分享看法
|
||||
```
|
||||
|
||||
## 输出格式
|
||||
|
||||
对每个选题,输出 5 套框架,每套包含:
|
||||
|
||||
```
|
||||
### 框架 X: {类型名}(推荐指数:⭐⭐⭐⭐⭐)
|
||||
|
||||
**开头策略**:{1-2 句话说明开头怎么写}
|
||||
|
||||
**段落大纲**:
|
||||
1. {H2 标题} — {这段写什么,2-3 句话}
|
||||
2. {H2 标题} — {这段写什么}
|
||||
3. ...
|
||||
|
||||
**金句预埋**:
|
||||
- {第 X 段结尾}:"{建议的金句方向}"
|
||||
- {第 X 段结尾}:"{建议的金句方向}"
|
||||
|
||||
**结尾引导**:{CTA 策略,1 句话}
|
||||
|
||||
**推荐理由**:{为什么这个选题适合用这套框架}
|
||||
```
|
||||
|
||||
## 推荐指数规则
|
||||
|
||||
根据选题特征和客户 content_style 匹配度打星:
|
||||
- ⭐⭐⭐⭐⭐ 最佳匹配
|
||||
- ⭐⭐⭐⭐ 适合
|
||||
- ⭐⭐⭐ 可以用但不是最优
|
||||
- ⭐⭐ 勉强
|
||||
- ⭐ 不建议
|
||||
|
||||
content_style 对应关系:
|
||||
- 干货型 → 优先推荐:痛点型、清单型
|
||||
- 故事型 → 优先推荐:故事型、热点解读型
|
||||
- 情绪型 → 优先推荐:故事型、痛点型
|
||||
- 热点型 → 优先推荐:热点解读型、对比型
|
||||
- 测评型 → 优先推荐:对比型、清单型
|
||||
62
references/seo-rules.md
Normal file
62
references/seo-rules.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# 微信公众号 SEO 规则
|
||||
|
||||
## 标题优化
|
||||
|
||||
微信标题限制 64 字符。最佳长度 **20-28 个中文字**——太短信息不够,太长在信息流里会被截断。标题是打开率的决定性因素。
|
||||
|
||||
**有效套路**:
|
||||
- 数字:「3 个方法」「90% 的人不知道」「5 分钟搞定」
|
||||
- 信息差:「你以为...其实...」「被忽略的...」
|
||||
- 反直觉:「为什么 X 反而更好」「别再...了」
|
||||
- 痛点:直接戳目标读者的具体问题
|
||||
|
||||
**避免**:
|
||||
- 标题党(震惊!必看!)— 微信会降权
|
||||
- 太学术(「论 AI 在企业数字化转型中的应用」)
|
||||
- 太模糊(「聊聊最近的一些想法」)
|
||||
|
||||
**输出要求**:生成 3 个备选标题,标注每个的策略(数字/信息差/反直觉/痛点)。
|
||||
|
||||
## 摘要优化
|
||||
|
||||
摘要限制 120 UTF-8 字节(约 54 个中文字,converter 自动截断)。
|
||||
|
||||
摘要出现在分享卡片和搜一搜结果中,要求:
|
||||
- 包含核心关键词
|
||||
- 制造悬念(「...结果出乎意料」)或给出价值承诺(「读完你会知道...」)
|
||||
- 不要重复标题
|
||||
|
||||
## 正文关键词
|
||||
|
||||
- 核心关键词在**前 200 字**内出现(微信搜一搜权重最高的区域)
|
||||
- 全文自然出现 3-5 次
|
||||
- 用同义词/近义词替换部分,避免堆砌感
|
||||
- 关键词出现在 H2 标题中加分
|
||||
|
||||
## 标签推荐
|
||||
|
||||
为文章推荐 **5 个精准标签**:
|
||||
- 2 个行业大词(如:人工智能、产品设计)
|
||||
- 2 个热点词(如:GPT-5、Sora)
|
||||
- 1 个长尾词(如:AI 产品经理转型)
|
||||
|
||||
## 完读率优化
|
||||
|
||||
完读率直接影响微信推荐权重。以下排版和内容策略提升完读率:
|
||||
|
||||
**段落控制**:
|
||||
- 每段不超过 150 字(手机屏幕上 4-5 行)
|
||||
- 每 3-4 段后设置一个"钩子"(悬念、反转、金句),防止读者中途退出
|
||||
|
||||
**视觉节奏**:
|
||||
- 每 400-500 字插入一张配图,打破纯文字的压迫感
|
||||
- 关键数据/结论用**加粗**标记,让扫读的读者也能抓住重点
|
||||
- H2 标题要有信息量(不要写"一、背景",要写"为什么 90% 的人都选错了")
|
||||
|
||||
**进度感**:
|
||||
- 清单型文章天然有进度感(读者知道"还有几条")
|
||||
- 其他类型文章,H2 标题数量控制在 2-4 个,让读者感觉"快看完了"
|
||||
|
||||
**结尾留存**:
|
||||
- 结尾不要太长(≤100 字)
|
||||
- CTA 要具体(不要"欢迎留言",要"你觉得哪个方案更靠谱?评论区聊聊")
|
||||
43
references/style-template.md
Normal file
43
references/style-template.md
Normal 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 | 极简黑白(适合文学/严肃内容) |
|
||||
106
references/topic-selection.md
Normal file
106
references/topic-selection.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# 选题评估规则
|
||||
|
||||
## 你的角色
|
||||
|
||||
你是一个公众号选题编辑。你的目标是从热点列表中挑出 10 个值得写的选题——既要有热度,又要跟客户定位匹配,还要有独特的切入角度。
|
||||
|
||||
## 输入
|
||||
|
||||
- 热点列表(JSON,包含 title/source/hot/url/description)
|
||||
- 客户 style.yaml 中的:topics、target_audience、blacklist、content_style
|
||||
- 客户 history.yaml 中的:已发布文章的 topic_keywords 和 stats(如有)
|
||||
- seo_keywords.py 输出:关键词的 seo_score 和 related_keywords(如有)
|
||||
|
||||
## 评估维度
|
||||
|
||||
对每个热点,按三个维度打分(1-10):
|
||||
|
||||
### 热度分(权重 30%)
|
||||
|
||||
看这个话题有多火:
|
||||
- 热搜前 10 → 8-10 分
|
||||
- 热搜 10-30 → 5-7 分
|
||||
- 30 名之后 → 1-4 分
|
||||
- 多个平台同时出现 → 加 2 分(封顶 10)
|
||||
|
||||
### 相关度分(权重 40%)
|
||||
|
||||
看这个话题跟客户定位有多契合:
|
||||
- 直接命中 topics 列表 → 8-10 分
|
||||
- 间接相关(比如客户做"AI",热点是"芯片出口管制")→ 5-7 分
|
||||
- 勉强能扯上关系 → 3-4 分
|
||||
- 完全无关 → 0 分
|
||||
- **命中 blacklist 的词汇或话题 → 直接判 0,整个选题淘汰**
|
||||
|
||||
### 切入价值分(权重 30%)
|
||||
|
||||
看这个话题写出来能不能好看:
|
||||
- 有明确的反直觉点或信息差 → 8-10 分
|
||||
- 有争议、有正反两面可以讨论 → 6-7 分
|
||||
- 纯资讯类、搬运即可 → 3-4 分
|
||||
- 太复杂不适合 2000 字展开,或太浅没东西可写 → 1-2 分
|
||||
|
||||
## content_style 加成
|
||||
|
||||
根据客户的 content_style,对切入价值分做加成:
|
||||
|
||||
| content_style | 加分条件 | 加分 |
|
||||
|---------------|---------|------|
|
||||
| 干货 | 选题能输出方法论/工具/教程 | +2 |
|
||||
| 故事 | 选题有人物、有情节、有转折 | +2 |
|
||||
| 情绪 | 选题能引发共鸣、愤怒、感动 | +2 |
|
||||
| 热点 | 选题正在热搜前 10 | +2 |
|
||||
| 测评 | 选题涉及产品/工具/方案对比 | +2 |
|
||||
|
||||
加成后封顶 10 分。
|
||||
|
||||
## 综合评分
|
||||
|
||||
```
|
||||
总分 = 热度 × 0.3 + 相关度 × 0.4 + 切入价值(含加成) × 0.3
|
||||
```
|
||||
|
||||
## 输出格式
|
||||
|
||||
列出 **Top 10 选题**(按总分降序),每个包含:
|
||||
|
||||
```
|
||||
### 选题 {序号}: {选题标题}(总分 X.X)
|
||||
|
||||
- 对应标题(20-28字):"{为这个选题拟的公众号标题}"
|
||||
- 切入角度:{1-2 句话说明怎么写、从什么角度切}
|
||||
- 热度:X/10 | 相关度:X/10 | 切入价值:X/10
|
||||
- 点击率潜力:{高/中/低} — {原因,如"标题含数字+反直觉,点击率高"}
|
||||
- SEO 友好度:{seo_score}/10 — {引用 seo_keywords.py 的数据,如"百度 8 + 360 10,相关词丰富"}
|
||||
- 推荐框架:{痛点型/故事型/清单型/对比型/热点解读型}
|
||||
- 推荐理由:{为什么这个值得写}
|
||||
- 历史标记:{如果 history.yaml 中近 7 天有相同关键词,标注"⚠️ 近期已覆盖类似话题"}
|
||||
```
|
||||
|
||||
## 历史去重规则
|
||||
|
||||
读取 history.yaml 中最近 30 天的文章记录,提取所有 topic_keywords。
|
||||
|
||||
- 如果选题的核心关键词在**最近 7 天**已出现 → 综合评分扣 3 分,并标注"⚠️ 近期已覆盖"
|
||||
- 如果在**7-30 天**内出现 → 综合评分扣 1 分,标注"ℹ️ 月内有相关文章"
|
||||
- 超过 30 天 → 不扣分
|
||||
|
||||
## 历史偏好参考
|
||||
|
||||
如果 history.yaml 中有带 stats 的文章(阅读量、分享量),分析表现最好的文章的共同特征:
|
||||
- 哪种框架类型表现好?→ 推荐框架时优先
|
||||
- 哪种标题风格表现好?(数字型/反直觉/痛点)→ 拟标题时参考
|
||||
- 不要强制套用——只作为参考信号,选题本身的质量仍然最重要
|
||||
|
||||
## 选题不足时的处理
|
||||
|
||||
- 如果能找到 10 个相关度 ≥ 5 的选题,直接输出
|
||||
- 如果只能找到 5-9 个,用相关度 3-4 的选题补齐到 10 个,但标注"相关度偏低"
|
||||
- 如果相关度 ≥ 5 的不足 5 个,告诉用户"今天热点跟你的领域匹配度不高",输出能找到的 + 建议用户自己给选题
|
||||
|
||||
## 注意
|
||||
|
||||
- 不要只挑热度最高的。一个热度 6 分但相关度 10 分的选题,往往比热度 10 分但相关度 3 分的更好
|
||||
- 每个选题必须配一个拟好的标题(20-28字),不是热点原标题
|
||||
- 推荐框架要根据选题特征和 content_style 来选,不要全推同一种
|
||||
- SEO 友好度必须引用 seo_keywords.py 的数据(如果有),不要纯靠猜
|
||||
152
references/visual-prompts.md
Normal file
152
references/visual-prompts.md
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
# 视觉AI模块
|
||||
|
||||
## 你的任务
|
||||
|
||||
为文章生成两类视觉素材的 AI 绘图提示词:封面图(3 组差异化创意)和内文配图(3-6 张,按段落匹配)。
|
||||
|
||||
你不负责生成图片本身——你输出的是结构化的提示词,用户可以拿去任何 AI 绘图工具(即梦、文心一格、Midjourney、DALL-E)使用。
|
||||
|
||||
---
|
||||
|
||||
## 一、封面图(3 组创意)
|
||||
|
||||
### 生成规则
|
||||
|
||||
每组创意走不同的视觉策略,确保差异化:
|
||||
|
||||
**创意 A: 直觉冲击型**
|
||||
- 策略:用一个视觉隐喻直接表达文章核心观点
|
||||
- 适合:热点类、观点类文章
|
||||
- 风格:大胆、对比强烈、第一眼抓眼球
|
||||
|
||||
**创意 B: 氛围渲染型**
|
||||
- 策略:营造一种情绪或场景氛围,引发好奇
|
||||
- 适合:故事类、情绪类文章
|
||||
- 风格:细腻、有质感、让人想点进去看
|
||||
|
||||
**创意 C: 信息图表型**
|
||||
- 策略:用简洁的图形/图标/数据可视化传递信息
|
||||
- 适合:干货类、清单类、测评类文章
|
||||
- 风格:简洁、专业、一眼看懂文章主题
|
||||
|
||||
### 提示词格式
|
||||
|
||||
每组输出:
|
||||
|
||||
```
|
||||
### 封面创意 A: {创意名称}
|
||||
- 视觉描述:{详细的画面描述,100-150字}
|
||||
- 色调:{主色+辅色}
|
||||
- 构图:{横版 16:9,主体位置、留白位置}
|
||||
- 文字区域:{标题放在什么位置,需要留多大空间}
|
||||
- AI 绘图提示词:
|
||||
"{英文提示词,适配主流 AI 绘图工具,包含风格、构图、色调、光影}"
|
||||
- 适配工具建议:{即梦/文心一格/Midjourney/DALL-E 中哪个最适合}
|
||||
```
|
||||
|
||||
### 提示词撰写要点
|
||||
|
||||
- 始终指定 `16:9 aspect ratio, horizontal composition`
|
||||
- 避免生成文字(AI 绘图工具生成的文字通常是乱码)
|
||||
- 指定 `no text, no letters, no words` 防止出现乱码文字
|
||||
- 为标题留出干净的空间:`clean space on the left/right/bottom for text overlay`
|
||||
- 色调与客户 style.yaml 的 cover_style 对齐
|
||||
- 风格关键词要具体:不说"好看",说"flat design, soft gradient, minimalist"
|
||||
|
||||
---
|
||||
|
||||
## 二、内文配图(3-6 张)
|
||||
|
||||
### 分析流程
|
||||
|
||||
写作完成后(Step 5 终稿),按以下步骤分析配图位置:
|
||||
|
||||
**第一步:提取结构**
|
||||
- 列出所有 H2 标题及其下属段落
|
||||
- 统计每个论点段落的字数和核心内容
|
||||
|
||||
**第二步:逐个论点判断**
|
||||
|
||||
对每个 H2 论点,判断是否需要配图:
|
||||
|
||||
| 需要配图(优先级高→低) | 不需要配图 |
|
||||
|-------------------------|-----------|
|
||||
| 有具体数据/统计 → 信息图强化 | 纯观点论述、篇幅短(<200字) |
|
||||
| 有场景描写 → 画面还原 | 已经有引用块或代码块(视觉已丰富) |
|
||||
| 转折/高潮处 → 视觉冲击 | 紧接着另一张配图(间距不足300字) |
|
||||
| 长段落后(>400字无图) → 节奏调节 | 结尾 CTA 段落 |
|
||||
|
||||
**第三步:确定位置**
|
||||
- 配图插入在对应段落**之后**(不是之前)
|
||||
- 具体到"H2 XX 下的第 N 段之后"
|
||||
|
||||
**约束规则**:
|
||||
- 总数 3-6 张(1500字→3张,2000字→4张,2500字→5-6张)
|
||||
- 相邻两张配图之间至少间隔 300 字
|
||||
- 不要在文章第一段之前放配图
|
||||
- 不要在结尾 CTA 段落放配图
|
||||
|
||||
### 提示词格式
|
||||
|
||||
每张输出:
|
||||
|
||||
```
|
||||
### 配图 {序号}: 位于「{H2标题}」第{N}段后
|
||||
- 配图目的:{信息强化/场景还原/节奏调节}
|
||||
- 对应内容:{这段讲了什么,1句话概括}
|
||||
- 画面描述:{具体的画面内容,80-120字}
|
||||
- AI 绘图提示词:
|
||||
"{中文提示词,给 doubao-seedream 用}"
|
||||
- 备选方案:{Unsplash/Pexels 搜索关键词}
|
||||
```
|
||||
|
||||
### 内文配图的特殊要求
|
||||
|
||||
- 尺寸统一 **16:9 横版**(image_gen.py --size article)
|
||||
- 风格与封面保持一致(同一色调体系)
|
||||
- 不要太复杂——手机屏幕上看,简洁的图比复杂的图好
|
||||
- 提示词用中文(seedream 中文理解强)
|
||||
- 每张图都提供一个**免费图库备选关键词**,以防生图效果不佳
|
||||
|
||||
---
|
||||
|
||||
## 三、辅助功能
|
||||
|
||||
### 提示词修改
|
||||
|
||||
如果用户说"封面创意 A 我喜欢方向但是想要更暖的色调",只修改对应创意的提示词,其他不变。
|
||||
|
||||
### 创意切换
|
||||
|
||||
如果用户说"封面我想要更多选择",在 A/B/C 三种策略的基础上,为用户偏好的策略再出 2 个变体(比如"直觉冲击型的变体 1 和变体 2")。
|
||||
|
||||
### 配图场景调整
|
||||
|
||||
如果用户说"第 3 张配图位置不对"或"这段不需要图",按用户要求增删调整。
|
||||
|
||||
---
|
||||
|
||||
## 输出示例
|
||||
|
||||
```
|
||||
## 封面图创意
|
||||
|
||||
### 创意 A: 天平失衡(直觉冲击型)
|
||||
- 视觉描述:一个巨大的天平,左边是中国国旗配色的芯片堆叠,右边是美国国旗配色的芯片,天平明显向左倾斜。背景是深蓝色数据流。
|
||||
- 色调:深蓝 + 科技蓝 + 金色点缀
|
||||
- 构图:16:9 横版,天平居中,右侧 1/3 留白放标题
|
||||
- 文字区域:右侧留出干净空间
|
||||
- AI 绘图提示词:
|
||||
"A large balance scale, left side stacked with red-themed microchips, right side with blue-themed microchips, scale tilting left, dark blue background with flowing data streams, flat design, minimalist, tech aesthetic, 16:9 aspect ratio, clean space on the right third for text overlay, no text no letters no words"
|
||||
- 适配工具建议:即梦(国内场景理解好)
|
||||
|
||||
## 内文配图
|
||||
|
||||
### 配图 1: 位于"数字背后是什么"段落后
|
||||
- 配图目的:信息强化
|
||||
- 画面描述:一个简洁的柱状图,展示中美大模型调用量的对比,中国柱子更高但带有问号标记
|
||||
- 尺寸:1:1 方形
|
||||
- AI 绘图提示词:
|
||||
"Minimalist bar chart comparing two bars, left bar taller in red, right bar shorter in blue, question mark floating above the taller bar, clean white background, flat infographic style, 1:1 square, no text"
|
||||
- 备选方案:Unsplash 搜 "data comparison chart technology"
|
||||
```
|
||||
426
references/wechat-constraints.md
Normal file
426
references/wechat-constraints.md
Normal 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
112
references/writing-guide.md
Normal 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 用于论点内的小节(可选,不要滥用)
|
||||
- 图片用相对路径:``
|
||||
- 不要用 HTML 标签,纯 Markdown
|
||||
- 不要用 `---` 分割线(微信渲染效果不好)
|
||||
|
||||
## 写后编辑指令
|
||||
|
||||
文章写完后,用户可能要求修改。支持以下编辑指令:
|
||||
|
||||
**润色**:保持内容不变,优化用词和句式,让表达更精准、更有文采。
|
||||
|
||||
**缩写**:保留核心观点,删减案例和展开,压缩到用户指定字数。优先砍最弱的论点段落。
|
||||
|
||||
**扩写**:在现有框架上补充案例、数据或展开论述,扩展到用户指定字数。不要加新论点,深化现有论点。
|
||||
|
||||
**换语气**:
|
||||
- 正式 → 去掉口语化表达,补充数据引用,语言更严谨
|
||||
- 口语 → 加入更多口语词、短句、反问,像在聊天
|
||||
- 情绪 → 加强共鸣点,放大痛点/爽点,结尾更煽动
|
||||
|
||||
编辑后覆盖保存到同一文件。
|
||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
markdown>=3.5
|
||||
beautifulsoup4>=4.12
|
||||
cssutils>=2.9
|
||||
requests>=2.31
|
||||
pyyaml>=6.0
|
||||
Pygments>=2.15
|
||||
Pillow>=10.0
|
||||
199
scripts/build_playbook.py
Normal file
199
scripts/build_playbook.py
Normal 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
167
scripts/fetch_hotspots.py
Normal 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
180
scripts/fetch_stats.py
Normal 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
275
scripts/learn_edits.py
Normal 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
119
scripts/seo_keywords.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
SEO keyword research tool.
|
||||
|
||||
Queries real search data to evaluate keyword popularity:
|
||||
1. Baidu search suggestions (autocomplete volume proxy)
|
||||
2. Baidu related searches
|
||||
3. WeChat sogou index (search volume proxy)
|
||||
|
||||
Usage:
|
||||
python3 seo_keywords.py "AI大模型"
|
||||
python3 seo_keywords.py "AI大模型" "科技股" "创业"
|
||||
python3 seo_keywords.py --json "AI大模型"
|
||||
|
||||
Output: keyword popularity score, related keywords, trending signals.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
import requests
|
||||
|
||||
TIMEOUT = 10
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/120.0.0.0 Safari/537.36",
|
||||
}
|
||||
|
||||
|
||||
def baidu_suggestions(keyword: str) -> list[str]:
|
||||
"""Get Baidu search autocomplete suggestions — proxy for search volume."""
|
||||
try:
|
||||
resp = requests.get(
|
||||
"https://suggestion.baidu.com/su",
|
||||
params={"wd": keyword, "action": "opensearch", "ie": "utf-8"},
|
||||
headers=HEADERS,
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
data = resp.json()
|
||||
# Response format: [query, [suggestions...]]
|
||||
if isinstance(data, list) and len(data) >= 2:
|
||||
return data[1]
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"[warn] baidu suggestions failed: {e}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
|
||||
def so360_suggestions(keyword: str) -> list[str]:
|
||||
"""Get 360 search suggestions — second source for search volume proxy."""
|
||||
try:
|
||||
resp = requests.get(
|
||||
"https://sug.so.360.cn/suggest",
|
||||
params={"word": keyword, "encodein": "utf-8", "encodeout": "utf-8", "format": "json"},
|
||||
headers=HEADERS,
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
data = resp.json()
|
||||
return [item.get("word", "") for item in data.get("result", []) if item.get("word")]
|
||||
except Exception as e:
|
||||
print(f"[warn] 360 suggestions failed: {e}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
|
||||
def analyze_keyword(keyword: str) -> dict:
|
||||
"""Analyze a keyword's SEO potential."""
|
||||
baidu_suggs = baidu_suggestions(keyword)
|
||||
so360_suggs = so360_suggestions(keyword)
|
||||
|
||||
# Popularity score (0-10) based on suggestion count
|
||||
# More suggestions = more search demand
|
||||
baidu_score = min(len(baidu_suggs), 10)
|
||||
so360_score = min(len(so360_suggs), 10)
|
||||
|
||||
# Combined score: average of two sources
|
||||
combined_score = round((baidu_score + so360_score) / 2, 1)
|
||||
|
||||
# Extract related keywords (dedup)
|
||||
all_related = list(dict.fromkeys(baidu_suggs + so360_suggs))
|
||||
|
||||
return {
|
||||
"keyword": keyword,
|
||||
"seo_score": combined_score,
|
||||
"baidu_score": baidu_score,
|
||||
"so360_score": so360_score,
|
||||
"baidu_suggestions": baidu_suggs[:5],
|
||||
"so360_suggestions": so360_suggs[:5],
|
||||
"related_keywords": all_related[:10],
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="SEO keyword analysis")
|
||||
parser.add_argument("keywords", nargs="+", help="Keywords to analyze")
|
||||
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
results = []
|
||||
for kw in args.keywords:
|
||||
result = analyze_keyword(kw)
|
||||
results.append(result)
|
||||
|
||||
if args.json:
|
||||
json.dump(results, sys.stdout, ensure_ascii=False, indent=2)
|
||||
else:
|
||||
for r in results:
|
||||
print(f"\n关键词: {r['keyword']}")
|
||||
print(f" 综合 SEO 评分: {r['seo_score']}/10(百度 {r['baidu_score']} + 360 {r['so360_score']})")
|
||||
if r["so360_suggestions"]:
|
||||
print(f" 360热搜词: {', '.join(r['so360_suggestions'][:5])}")
|
||||
if r["related_keywords"]:
|
||||
print(f" 相关关键词: {', '.join(r['related_keywords'][:5])}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
187
toolkit/cli.py
Normal file
187
toolkit/cli.py
Normal 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
242
toolkit/converter.py
Normal 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
318
toolkit/image_gen.py
Normal 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
62
toolkit/publisher.py
Normal 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
197
toolkit/theme.py
Normal 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
186
toolkit/themes/minimal.yaml
Normal 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;
|
||||
}
|
||||
188
toolkit/themes/professional-clean.yaml
Normal file
188
toolkit/themes/professional-clean.yaml
Normal 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;
|
||||
}
|
||||
196
toolkit/themes/tech-modern.yaml
Normal file
196
toolkit/themes/tech-modern.yaml
Normal 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;
|
||||
}
|
||||
188
toolkit/themes/warm-editorial.yaml
Normal file
188
toolkit/themes/warm-editorial.yaml
Normal 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
115
toolkit/wechat_api.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import time
|
||||
import mimetypes
|
||||
import requests
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
|
||||
# Token cache
|
||||
_token_cache: dict = {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenResult:
|
||||
access_token: str
|
||||
expires_at: float # unix timestamp
|
||||
|
||||
|
||||
def get_access_token(appid: str, secret: str, force_refresh: bool = False) -> str:
|
||||
"""
|
||||
Get access_token with caching.
|
||||
Cache key: appid
|
||||
API: GET https://api.weixin.qq.com/cgi-bin/token
|
||||
Cache until expires_in - 300 seconds (5 min buffer).
|
||||
Raise ValueError on API error.
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
if not force_refresh and appid in _token_cache:
|
||||
cached: TokenResult = _token_cache[appid]
|
||||
if now < cached.expires_at:
|
||||
return cached.access_token
|
||||
|
||||
resp = requests.get(
|
||||
"https://api.weixin.qq.com/cgi-bin/token",
|
||||
params={
|
||||
"grant_type": "client_credential",
|
||||
"appid": appid,
|
||||
"secret": secret,
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
|
||||
if "access_token" not in data:
|
||||
errcode = data.get("errcode", "unknown")
|
||||
errmsg = data.get("errmsg", "unknown error")
|
||||
raise ValueError(f"WeChat API error: errcode={errcode}, errmsg={errmsg}")
|
||||
|
||||
access_token = data["access_token"]
|
||||
expires_in = data.get("expires_in", 7200)
|
||||
|
||||
_token_cache[appid] = TokenResult(
|
||||
access_token=access_token,
|
||||
expires_at=now + expires_in - 300,
|
||||
)
|
||||
|
||||
return access_token
|
||||
|
||||
|
||||
def _guess_content_type(file_path: str) -> str:
|
||||
"""Detect content type from file extension."""
|
||||
content_type, _ = mimetypes.guess_type(file_path)
|
||||
return content_type or "application/octet-stream"
|
||||
|
||||
|
||||
def upload_image(access_token: str, image_path: str) -> str:
|
||||
"""
|
||||
Upload image for use inside article content.
|
||||
API: POST https://api.weixin.qq.com/cgi-bin/media/uploadimg
|
||||
Returns the url string.
|
||||
Raise ValueError on error.
|
||||
"""
|
||||
path = Path(image_path)
|
||||
content_type = _guess_content_type(image_path)
|
||||
|
||||
with open(path, "rb") as f:
|
||||
resp = requests.post(
|
||||
"https://api.weixin.qq.com/cgi-bin/media/uploadimg",
|
||||
params={"access_token": access_token},
|
||||
files={"media": (path.name, f, content_type)},
|
||||
)
|
||||
|
||||
data = resp.json()
|
||||
|
||||
if "url" not in data:
|
||||
errcode = data.get("errcode", "unknown")
|
||||
errmsg = data.get("errmsg", "unknown error")
|
||||
raise ValueError(f"WeChat upload_image error: errcode={errcode}, errmsg={errmsg}")
|
||||
|
||||
return data["url"]
|
||||
|
||||
|
||||
def upload_thumb(access_token: str, image_path: str) -> str:
|
||||
"""
|
||||
Upload cover image as permanent material.
|
||||
API: POST https://api.weixin.qq.com/cgi-bin/material/add_material
|
||||
Returns media_id string.
|
||||
Raise ValueError on error.
|
||||
"""
|
||||
path = Path(image_path)
|
||||
content_type = _guess_content_type(image_path)
|
||||
|
||||
with open(path, "rb") as f:
|
||||
resp = requests.post(
|
||||
"https://api.weixin.qq.com/cgi-bin/material/add_material",
|
||||
params={"access_token": access_token, "type": "image"},
|
||||
files={"media": (path.name, f, content_type)},
|
||||
)
|
||||
|
||||
data = resp.json()
|
||||
|
||||
if "media_id" not in data:
|
||||
errcode = data.get("errcode", "unknown")
|
||||
errmsg = data.get("errmsg", "unknown error")
|
||||
raise ValueError(f"WeChat upload_thumb error: errcode={errcode}, errmsg={errmsg}")
|
||||
|
||||
return data["media_id"]
|
||||
Loading…
Reference in a new issue