diff --git a/Dockerfile b/Dockerfile index 976dfdb..ee1627e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ RUN apt-get update && apt-get install -y \ ca-certificates \ libpq-dev \ chromium \ + ffmpeg \ && rm -rf /var/lib/apt/lists/* # 安装Node.js (支持npx命令) diff --git a/Dockerfile.modelscope b/Dockerfile.modelscope index f5fbd55..206f03c 100644 --- a/Dockerfile.modelscope +++ b/Dockerfile.modelscope @@ -17,6 +17,7 @@ RUN sed -i 's|http://deb.debian.org|http://mirrors.aliyun.com|g' /etc/apt/source ca-certificates \ libpq-dev \ chromium \ + ffmpeg \ && rm -rf /var/lib/apt/lists/* # 安装Node.js (支持npx命令) diff --git a/skills_developing/ai-ppt-generator/SKILL.md b/skills_developing/ai-ppt-generator/SKILL.md new file mode 100644 index 0000000..166913e --- /dev/null +++ b/skills_developing/ai-ppt-generator/SKILL.md @@ -0,0 +1,85 @@ +--- +name: ai-ppt-generator +description: Generate PPT with Baidu AI. Smart template selection based on content. +metadata: { "openclaw": { "emoji": "📑", "requires": { "bins": ["python3"], "env":["BAIDU_API_KEY"]},"primaryEnv":"BAIDU_API_KEY" } } +--- + +# AI PPT Generator + +Generate PPT using Baidu AI with intelligent template selection. + +## Smart Workflow +1. **User provides PPT topic** +2. **Agent asks**: "Want to choose a template style?" +3. **If yes** → Show styles from `ppt_theme_list.py` → User picks → Use `generate_ppt.py` with chosen `tpl_id` and real `style_id` +4. **If no** → Use `random_ppt_theme.py` (auto-selects appropriate template based on topic content) + +## Intelligent Template Selection +`random_ppt_theme.py` analyzes the topic and suggests appropriate template: +- **Business topics** → 企业商务 style +- **Technology topics** → 未来科技 style +- **Education topics** → 卡通手绘 style +- **Creative topics** → 创意趣味 style +- **Cultural topics** → 中国风 or 文化艺术 style +- **Year-end reports** → 年终总结 style +- **Minimalist design** → 扁平简约 style +- **Artistic content** → 文艺清新 style + +## Scripts +- `scripts/ppt_theme_list.py` - List all available templates with style_id and tpl_id +- `scripts/random_ppt_theme.py` - Smart template selection + generate PPT +- `scripts/generate_ppt.py` - Generate PPT with specific template (uses real style_id and tpl_id from API) + +## Key Features +- **Smart categorization**: Analyzes topic content to suggest appropriate style +- **Fallback logic**: If template not found, automatically uses random selection +- **Complete parameters**: Properly passes both style_id and tpl_id to API + +## Usage Examples +```bash +# List all templates with IDs +python3 scripts/ppt_theme_list.py + +# Smart automatic selection (recommended for most users) +python3 scripts/random_ppt_theme.py --query "人工智能发展趋势报告" + +# Specific template with proper style_id +python3 scripts/generate_ppt.py --query "儿童英语课件" --tpl_id 106 + +# Specific template with auto-suggested category +python3 scripts/random_ppt_theme.py --query "企业年度总结" --category "企业商务" +``` + +## Agent Steps +1. Get PPT topic from user +2. Ask: "Want to choose a template style?" +3. **If user says YES**: + - Run `ppt_theme_list.py` to show available templates + - User selects a template (note the tpl_id) + - Run `generate_ppt.py --query "TOPIC" --tpl_id ID` +4. **If user says NO**: + - Run `random_ppt_theme.py --query "TOPIC"` + - Script will auto-select appropriate template based on topic +5. Set timeout to 300 seconds (PPT generation takes 2-5 minutes) +6. Monitor output, wait for `is_end: true` to get final PPT URL + +## Output Examples +**During generation:** +```json +{"status": "PPT生成中", "run_time": 45} +``` + +**Final result:** +```json +{ + "status": "PPT导出结束", + "is_end": true, + "data": {"ppt_url": "https://image0.bj.bcebos.com/...ppt"} +} +``` + +## Technical Notes +- **API integration**: Fetches real style_id from Baidu API for each template +- **Error handling**: If template not found, falls back to random selection +- **Timeout**: Generation takes 2-5 minutes, set sufficient timeout +- **Streaming**: Uses streaming API, wait for `is_end: true` before considering complete \ No newline at end of file diff --git a/skills_developing/ai-ppt-generator/_meta.json b/skills_developing/ai-ppt-generator/_meta.json new file mode 100644 index 0000000..c58276d --- /dev/null +++ b/skills_developing/ai-ppt-generator/_meta.json @@ -0,0 +1,6 @@ +{ + "ownerId": "kn7akgt520t01vgs2tzx7yk6m180kt26", + "slug": "ai-ppt-generator", + "version": "1.1.4", + "publishedAt": 1773656502997 +} \ No newline at end of file diff --git a/skills_developing/ai-ppt-generator/scripts/generate_ppt.py b/skills_developing/ai-ppt-generator/scripts/generate_ppt.py new file mode 100644 index 0000000..2d3ed58 --- /dev/null +++ b/skills_developing/ai-ppt-generator/scripts/generate_ppt.py @@ -0,0 +1,148 @@ +import os +import random +import sys +import time + +import requests +import json +import argparse + +URL_PREFIX = "https://qianfan.baidubce.com/v2/tools/ai_ppt/" + + +class Style: + def __init__(self, style_id, tpl_id): + self.style_id = style_id + self.tpl_id = tpl_id + + +class Outline: + def __init__(self, chat_id, query_id, title, outline): + self.chat_id = chat_id + self.query_id = query_id + self.title = title + self.outline = outline + + +def get_ppt_theme(api_key: str): + """Get a random PPT theme""" + headers = { + "Authorization": "Bearer %s" % api_key, + } + response = requests.post(URL_PREFIX + "get_ppt_theme", headers=headers) + result = response.json() + if "errno" in result and result["errno"] != 0: + raise RuntimeError(result["errmsg"]) + + style_index = random.randint(0, len(result["data"]["ppt_themes"]) - 1) + theme = result["data"]["ppt_themes"][style_index] + return Style(style_id=theme["style_id"], tpl_id=theme["tpl_id"]) + + +def ppt_outline_generate(api_key: str, query: str): + """Generate PPT outline""" + headers = { + "Authorization": "Bearer %s" % api_key, + "X-Appbuilder-From": "openclaw", + "Content-Type": "application/json" + } + headers.setdefault('Accept', 'text/event-stream') + headers.setdefault('Cache-Control', 'no-cache') + headers.setdefault('Connection', 'keep-alive') + params = { + "query": query, + } + title = "" + outline = "" + chat_id = "" + query_id = "" + with requests.post(URL_PREFIX + "generate_outline", headers=headers, json=params, stream=True) as response: + for line in response.iter_lines(): + line = line.decode('utf-8') + if line and line.startswith("data:"): + data_str = line[5:].strip() + delta = json.loads(data_str) + if not title: + title = delta["title"] + chat_id = delta["chat_id"] + query_id = delta["query_id"] + outline += delta["outline"] + + return Outline(chat_id=chat_id, query_id=query_id, title=title, outline=outline) + + +def ppt_generate(api_key: str, query: str, style_id: int = 0, tpl_id: int = None, web_content: str = None): + """Generate PPT - simple version""" + headers = { + "Authorization": "Bearer %s" % api_key, + "Content-Type": "application/json", + "X-Appbuilder-From": "openclaw", + } + + # Get theme + if tpl_id is None: + # Random theme + style = get_ppt_theme(api_key) + style_id = style.style_id + tpl_id = style.tpl_id + print(f"Using random template (tpl_id: {tpl_id})", file=sys.stderr) + else: + # Specific theme - use provided style_id (default 0) + print(f"Using template tpl_id: {tpl_id}, style_id: {style_id}", file=sys.stderr) + + # Generate outline + outline = ppt_outline_generate(api_key, query) + + # Generate PPT + headers.setdefault('Accept', 'text/event-stream') + headers.setdefault('Cache-Control', 'no-cache') + headers.setdefault('Connection', 'keep-alive') + params = { + "query_id": int(outline.query_id), + "chat_id": int(outline.chat_id), + "query": query, + "outline": outline.outline, + "title": outline.title, + "style_id": style_id, + "tpl_id": tpl_id, + "web_content": web_content, + "enable_save_bos": True, + } + with requests.post(URL_PREFIX + "generate_ppt_by_outline", headers=headers, json=params, stream=True) as response: + if response.status_code != 200: + print(f"request failed, status code is {response.status_code}, error message is {response.text}") + return [] + for line in response.iter_lines(): + line = line.decode('utf-8') + if line and line.startswith("data:"): + data_str = line[5:].strip() + yield json.loads(data_str) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Generate PPT") + parser.add_argument("--query", "-q", type=str, required=True, help="PPT topic") + parser.add_argument("--style_id", "-si", type=int, default=0, help="Style ID (default: 0)") + parser.add_argument("--tpl_id", "-tp", type=int, help="Template ID (optional)") + parser.add_argument("--web_content", "-wc", type=str, default=None, help="Web content") + args = parser.parse_args() + + api_key = os.getenv("BAIDU_API_KEY") + if not api_key: + print("Error: BAIDU_API_KEY must be set in environment.") + sys.exit(1) + + try: + start_time = int(time.time()) + results = ppt_generate(api_key, args.query, args.style_id, args.tpl_id, args.web_content) + + for result in results: + if "is_end" in result and result["is_end"]: + print(json.dumps(result, ensure_ascii=False, indent=2)) + else: + end_time = int(time.time()) + print(json.dumps({"status": result["status"], "run_time": end_time - start_time})) + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) \ No newline at end of file diff --git a/skills_developing/ai-ppt-generator/scripts/ppt_theme_list.py b/skills_developing/ai-ppt-generator/scripts/ppt_theme_list.py new file mode 100644 index 0000000..05f8b9f --- /dev/null +++ b/skills_developing/ai-ppt-generator/scripts/ppt_theme_list.py @@ -0,0 +1,43 @@ +import os +import sys +import requests +import json + + +def ppt_theme_list(api_key: str): + url = "https://qianfan.baidubce.com/v2/tools/ai_ppt/get_ppt_theme" + headers = { + "Authorization": "Bearer %s" % api_key, + "X-Appbuilder-From": "openclaw", + } + response = requests.post(url, headers=headers) + result = response.json() + if "errno" in result and result["errno"] != 0: + raise RuntimeError(result["errmsg"]) + themes = [] + count = 0 + for theme in result["data"]["ppt_themes"]: + count += 1 + if count > 100: + break + themes.append({ + "style_name_list": theme["style_name_list"], + "style_id": theme["style_id"], + "tpl_id": theme["tpl_id"], + }) + return themes + + +if __name__ == "__main__": + api_key = os.getenv("BAIDU_API_KEY") + if not api_key: + print("Error: BAIDU_API_KEY must be set in environment.") + sys.exit(1) + try: + results = ppt_theme_list(api_key) + print(json.dumps(results, ensure_ascii=False, indent=2)) + except Exception as e: + exc_type, exc_value, exc_traceback = sys.exc_info() + print(f"error type:{exc_type}") + print(f"error message:{exc_value}") + sys.exit(1) \ No newline at end of file diff --git a/skills_developing/ai-ppt-generator/scripts/random_ppt_theme.py b/skills_developing/ai-ppt-generator/scripts/random_ppt_theme.py new file mode 100644 index 0000000..b3c47df --- /dev/null +++ b/skills_developing/ai-ppt-generator/scripts/random_ppt_theme.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +""" +Random PPT Theme Selector +If user doesn't select a PPT template, this script will randomly select one +from the available templates and generate PPT. +""" + +import os +import sys +import json +import random +import argparse +import subprocess +import time +def get_available_themes(): + """Get available PPT themes""" + try: + api_key = os.getenv("BAIDU_API_KEY") + if not api_key: + print("Error: BAIDU_API_KEY environment variable not set", file=sys.stderr) + return [] + + # Import the function from ppt_theme_list.py + script_dir = os.path.dirname(os.path.abspath(__file__)) + sys.path.insert(0, script_dir) + + from ppt_theme_list import ppt_theme_list as get_themes + themes = get_themes(api_key) + return themes + except Exception as e: + print(f"Error getting themes: {e}", file=sys.stderr) + return [] + + + +def categorize_themes(themes): + """Categorize themes by style for better random selection""" + categorized = { + "企业商务": [], + "文艺清新": [], + "卡通手绘": [], + "扁平简约": [], + "中国风": [], + "年终总结": [], + "创意趣味": [], + "文化艺术": [], + "未来科技": [], + "默认": [] + } + + for theme in themes: + style_names = theme.get("style_name_list", []) + if not style_names: + categorized["默认"].append(theme) + continue + + added = False + for style_name in style_names: + if style_name in categorized: + categorized[style_name].append(theme) + added = True + break + + if not added: + categorized["默认"].append(theme) + + return categorized + + +def select_random_theme_by_category(categorized_themes, preferred_category=None): + """Select a random theme, optionally preferring a specific category""" + # If preferred category specified and has themes, use it + if preferred_category and preferred_category in categorized_themes: + if categorized_themes[preferred_category]: + return random.choice(categorized_themes[preferred_category]) + + # Otherwise, select from all non-empty categories + available_categories = [] + for category, themes in categorized_themes.items(): + if themes: + available_categories.append(category) + + if not available_categories: + return None + + # Weighted random selection: prefer non-default categories + weights = [] + for category in available_categories: + if category == "默认": + weights.append(0.5) # Lower weight for default + else: + weights.append(2.0) # Higher weight for specific styles + + # Normalize weights + total_weight = sum(weights) + weights = [w/total_weight for w in weights] + + selected_category = random.choices(available_categories, weights=weights, k=1)[0] + return random.choice(categorized_themes[selected_category]) + + +def suggest_category_by_query(query): + """Suggest template category based on query keywords - enhanced version""" + query_lower = query.lower() + + # Comprehensive keyword mapping with priority order + keyword_mapping = [ + # Business & Corporate (highest priority for formal content) + ("企业商务", [ + "企业", "公司", "商务", "商业", "商务", "商业计划", "商业报告", + "营销", "市场", "销售", "财务", "会计", "审计", "投资", "融资", + "战略", "管理", "运营", "人力资源", "hr", "董事会", "股东", + "年报", "季报", "财报", "业绩", "kpi", "okr", "商业计划书", + "提案", "策划", "方案", "报告", "总结", "规划", "计划" + ]), + + # Technology & Future Tech + ("未来科技", [ + "未来", "科技", "人工智能", "ai", "机器学习", "深度学习", + "大数据", "云计算", "区块链", "物联网", "iot", "5g", "6g", + "量子计算", "机器人", "自动化", "智能制造", "智慧城市", + "虚拟现实", "vr", "增强现实", "ar", "元宇宙", "数字孪生", + "芯片", "半导体", "集成电路", "电子", "通信", "网络", + "网络安全", "信息安全", "数字化", "数字化转型", + "科幻", "高科技", "前沿科技", "科技创新", "技术" + ]), + + # Education & Children + ("卡通手绘", [ + "卡通", "动画", "动漫", "儿童", "幼儿", "小学生", "中学生", + "教育", "教学", "课件", "教案", "学习", "培训", "教程", + "趣味", "有趣", "可爱", "活泼", "生动", "绘本", "漫画", + "手绘", "插画", "图画", "图形", "游戏", "玩乐", "娱乐" + ]), + + # Year-end & Summary + ("年终总结", [ + "年终", "年度", "季度", "月度", "周报", "日报", + "总结", "回顾", "汇报", "述职", "考核", "评估", + "成果", "成绩", "业绩", "绩效", "目标", "完成", + "工作汇报", "工作总结", "年度报告", "季度报告" + ]), + + # Minimalist & Modern Design + ("扁平简约", [ + "简约", "简洁", "简单", "极简", "现代", "当代", + "设计", "视觉", "ui", "ux", "用户体验", "用户界面", + "科技感", "数字感", "数据", "图表", "图形", "信息图", + "分析", "统计", "报表", "dashboard", "仪表板", + "互联网", "web", "移动", "app", "应用", "软件" + ]), + + # Chinese Traditional + ("中国风", [ + "中国", "中华", "传统", "古典", "古风", "古代", + "文化", "文明", "历史", "国学", "东方", "水墨", + "书法", "国画", "诗词", "古文", "经典", "传统节日", + "春节", "中秋", "端午", "节气", "风水", "易经", + "儒", "道", "佛", "禅", "茶道", "瓷器", "丝绸" + ]), + + # Cultural & Artistic + ("文化艺术", [ + "文化", "艺术", "文艺", "美学", "审美", "创意", + "创作", "作品", "展览", "博物馆", "美术馆", "画廊", + "音乐", "舞蹈", "戏剧", "戏曲", "电影", "影视", + "摄影", "绘画", "雕塑", "建筑", "设计", "时尚", + "文学", "诗歌", "小说", "散文", "哲学", "思想" + ]), + + # Artistic & Fresh + ("文艺清新", [ + "文艺", "清新", "小清新", "治愈", "温暖", "温柔", + "浪漫", "唯美", "优雅", "精致", "细腻", "柔和", + "自然", "生态", "环保", "绿色", "植物", "花卉", + "风景", "旅行", "游记", "生活", "日常", "情感" + ]), + + # Creative & Fun + ("创意趣味", [ + "创意", "创新", "创造", "发明", "新奇", "新颖", + "独特", "个性", "特色", "趣味", "有趣", "好玩", + "幽默", "搞笑", "笑话", "娱乐", "休闲", "放松", + "脑洞", "想象力", "灵感", "点子", "想法", "概念" + ]), + + # Academic & Research + ("默认", [ + "研究", "学术", "科学", "论文", "课题", "项目", + "实验", "调查", "分析", "理论", "方法", "技术", + "医学", "健康", "医疗", "生物", "化学", "物理", + "数学", "工程", "建筑", "法律", "政治", "经济", + "社会", "心理", "教育", "学习", "知识", "信息" + ]) + ] + + # Check each category with its keywords + for category, keywords in keyword_mapping: + for keyword in keywords: + if keyword in query_lower: + return category + + # If no match found, analyze query length and content + words = query_lower.split() + if len(words) <= 3: + # Short query, likely specific - use "默认" or tech-related + if any(word in query_lower for word in ["ai", "vr", "ar", "iot", "5g", "tech"]): + return "未来科技" + return "默认" + else: + # Longer query, analyze word frequency + word_counts = {} + for word in words: + if len(word) > 1: # Ignore single characters + word_counts[word] = word_counts.get(word, 0) + 1 + + # Check for business indicators + business_words = ["报告", "总结", "计划", "方案", "业绩", "销售", "市场"] + if any(word in word_counts for word in business_words): + return "企业商务" + + # Check for tech indicators + tech_words = ["技术", "科技", "数据", "数字", "智能", "系统"] + if any(word in word_counts for word in tech_words): + return "未来科技" + + # Default fallback + return "默认" + + +def generate_ppt_with_random_theme(query, preferred_category=None): + """Generate PPT with randomly selected theme""" + # Get available themes + themes = get_available_themes() + if not themes: + print("Error: No available themes found", file=sys.stderr) + return False + + # Categorize themes + categorized = categorize_themes(themes) + + # Select random theme + selected_theme = select_random_theme_by_category(categorized, preferred_category) + if not selected_theme: + print("Error: Could not select a theme", file=sys.stderr) + return False + + style_id = selected_theme.get("style_id", 0) + tpl_id = selected_theme.get("tpl_id") + style_names = selected_theme.get("style_name_list", ["默认"]) + + print(f"Selected template: {style_names[0]} (tpl_id: {tpl_id})", file=sys.stderr) + + # Generate PPT + script_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "generate_ppt.py") + + try: + # Run generate_ppt.py with the selected theme + cmd = [ + sys.executable, script_path, + "--query", query, + "--tpl_id", str(tpl_id), + "--style_id", str(style_id) + ] + + start_time = int(time.time()) + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + universal_newlines=True + ) + + # Stream output + for line in process.stdout: + line = line.strip() + if line: + try: + data = json.loads(line) + if "is_end" in data and data["is_end"]: + print(json.dumps(data, ensure_ascii=False)) + else: + end_time = int(time.time()) + print(json.dumps({"status": data.get("status", "生成中"), "run_time": end_time - start_time}, ensure_ascii=False)) + except json.JSONDecodeError: + # Just print non-JSON output + print(line) + + process.wait() + return process.returncode == 0 + + except Exception as e: + print(f"Error generating PPT: {e}", file=sys.stderr) + return False + + +def main(): + parser = argparse.ArgumentParser(description="Generate PPT with random theme selection") + parser.add_argument("--query", "-q", type=str, required=True, help="PPT主题/内容") + parser.add_argument("--category", "-c", type=str, help="Preferred category (企业商务/文艺清新/卡通手绘/扁平简约/中国风/年终总结/创意趣味/文化艺术/未来科技)") + + args = parser.parse_args() + + # Determine preferred category + preferred_category = args.category + if not preferred_category: + preferred_category = suggest_category_by_query(args.query) + if preferred_category: + print(f"Auto-suggested category: {preferred_category}", file=sys.stderr) + + # Generate PPT + success = generate_ppt_with_random_theme(args.query, preferred_category) + + if not success: + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/skills_developing/z-card-image/README.md b/skills_developing/z-card-image/README.md new file mode 100644 index 0000000..043b57e --- /dev/null +++ b/skills_developing/z-card-image/README.md @@ -0,0 +1,50 @@ +# z-card-image + +文字卡片图片生成器,把文案渲染成 PNG,支持公众号、小红书等多种配色风格。 + +## 效果 + +- 输入一段文字,自动排版成精美卡片图 +- 支持多行文字 + 高亮、品牌 footer、顶部图标 +- 默认模板:3:4 竖版(900×1200) + +## 依赖 + +- Python 3 +- Google Chrome(macOS:`/Applications/Google Chrome.app`) + +## 自定义品牌信息 + +默认配色和 footer 是「早早集市」的风格,改成你自己的只需修改 `SKILL.md` 里的**平台预设**表格: + +```markdown +## 平台预设 + +| 平台 | `--footer` | `--bg` | `--highlight` | +|------|-----------|--------|--------------| +| 公众号(默认) | `公众号 · 你的名字` | `#e6f5ef` | `#22a854` | +| 小红书 | `小红书 · 你的名字` | `#fdecea` | `#e53935` | +``` + +`render_card.py` 支持的所有参数: + +| 参数 | 说明 | 默认值 | +|------|------|--------| +| `--footer` | 底部署名文字 | `公众号 · 早早集市` | +| `--bg` | 背景色(hex) | `#e6f5ef` | +| `--highlight` | 高亮色(hex) | `#22a854` | +| `--icon` | 顶部图标路径 | 自动判断 | +| `--template` | 模板名 | `xiaolvs-cover` | + +把 `--footer` 改成你的公众号名,`--bg` / `--highlight` 改成你的品牌色,就是你的专属风格了。 + +## 替换 Logo + +顶部图标在 `assets/icons/` 目录,替换对应 SVG/JPG 文件即可,文件名保持一致。 + +## 新增模板 + +1. 新建 `assets/templates/.html` +2. 在 `scripts/render_card.py` 的 `size_map` 里注册尺寸 +3. 在 `SKILL.md` 模板索引中添加一行 +4. 创建 `references/.md` 记录参数和字数上限 diff --git a/skills_developing/z-card-image/SKILL.md b/skills_developing/z-card-image/SKILL.md new file mode 100644 index 0000000..35a794a --- /dev/null +++ b/skills_developing/z-card-image/SKILL.md @@ -0,0 +1,90 @@ +--- +name: z-card-image +version: 1.1.0 +description: 生成配图、封面图、卡片图、文字海报、公众号文章封面图、微信公众号头图、X 风格帖子分享图、帖子长图、社媒帖子长图。适用于帖子类型数据、post data、social posts、tweet/thread、转发推文、转发帖子、小绿书配图、图片封面、card image。 +metadata: + openclaw: + requires: + bins: + - python3 + - google-chrome +--- + +# z-card-image + +将用户提供的文案渲染成 PNG 卡片图。 +支持短文案封面图、长文分页图、X 风格帖子分享长图,以及公众号文章封面图。只要输入是“帖子类型数据”并希望导出成 X 风格长图,都应走 `x-like-posts`。 + +## 环境要求 + +- Python 3 +- Google Chrome(macOS:`/Applications/Google Chrome.app`;Linux:`chromium` 需修改脚本路径) + +## 执行流程 + +0. **环境提示**(用户触发时检测一次,有问题给提示,不中止流程): + - `python3 --version` → 失败则告知:「⚠️ 未检测到 Python 3,渲染可能失败」 + - 检查 Chrome 路径 → 失败则提示安装 + +1. **识别场景**: + - 短文案封面图 → `poster-3-4` + - 长文分页图 → `article-3-4` + - X 风格帖子分享图 / 帖子长图 / 帖子类型数据 → `x-like-posts` + - 公众号文章封面图 → `wechat-cover-split` +2. **查模板规则**:根据模板在「模板索引」中找到对应规范文档,读取后按其规则处理文案和参数。**如用户要求高亮:整行用 `--hl1/hl2/hl3`,按词用 `--highlight-words`(逗号分隔),两者可同时使用,不能忽略** +3. **确认署名**:先询问用户底部署名文字(`--footer`),如:「请告诉我你的署名,例如"公众号 · 你的名字"」。用户未回答或要求跳过时,使用脚本默认值。 +4. **确定配色**:拿到署名后,再确定配色方案: + - 用户提到"小红书配图" → 推荐方案 B(热情红) + - 用户提到"小绿书"或"公众号配图" → 推荐方案 A(清新绿) + - 用户提到"推特长图"/"X 风格" → 使用 `render_x_like_posts.py` 自带默认值(Twitter 蓝白灰) + - 其他场景 → 询问用户选择下方配色方案: + + | 方案 | 风格 | `--bg` | `--highlight` | 适用场景 | + |------|------|--------|--------------|---------| + | A. 清新绿 | 公众号/小绿书 | `#e6f5ef` | `#22a854` | 公众号配图、小绿书 | + | B. 热情红 | 小红书 | `#fdecea` | `#e53935` | 小红书配图 | + | C. 科技蓝 | 知乎/技术 | `#e8f4fd` | `#1976d2` | 技术文章、知乎 | + | D. 暖橙黄 | 活力/营销 | `#fff8e1` | `#f57c00` | 活动海报、营销 | + | E. 优雅紫 | 时尚/文艺 | `#f3e5f5` | `#7b1fa2` | 文艺、时尚类 | + | F. 经典黑白 | 极简 | `#f5f5f5` | `#212121` | 极简风格 | + + 用户也可自定义 `--bg` 和 `--highlight`。用户未回答或要求跳过时,使用脚本默认值,不做额外覆盖。 +5. **渲染输出**: + - `poster-3-4` → 执行 `render_card.py` + - `article-3-4` → 执行 `render_article.py` + - `x-like-posts` → 执行 `render_x_like_posts.py` + - `wechat-cover-split` → 执行 `render_card.py` + - 默认 `--out` 填 `tmp/...png`;如用户指定导出位置,可直接传绝对路径或相对路径 +6. **输出产物**:生成 PNG 到指定路径,供后续发送、裁切或复用;如需给外部工具上传,仍应避免写入系统 `/tmp/` + +## x-like-posts 导航 + +`x-like-posts` 用于“帖子类型数据 → X 风格分享长图”。 + +当命中这条路线时,继续读取: + +- [references/x-like-posts.md](references/x-like-posts.md):输入 JSON 格式、可显示字段、时间规则、导出规则 +- [references/tweet-thread.md](references/tweet-thread.md):旧命名兼容说明 + +## 输入校验 + +- **比例不存在**:驳回请求,告知当前支持的比例列表,询问是否新增模板 +- **文案超出模板字数上限**:先自动拆分/缩写后再渲染,不要直接塞入 +- **帖子过多**:按规范拆成多张 `Part 1 / Part 2`,不要把超长内容强行塞进一张 +- **公众号封面标题过长**:先压缩成 2~3 行短标题,再渲染,不能把完整长标题硬塞进模板 + +## 模板索引 + +| 模板名 | 比例 | 尺寸 | 用途 | 规范文档 | +|--------|------|------|------|---------| +| `poster-3-4` | 3:4 | 900×1200 | 文字海报(金句/大字报/封面) | [references/poster-3-4.md](references/poster-3-4.md) | +| `article-3-4` | 3:4 | 900×1200 | 长文分页卡片 | [references/article-3-4.md](references/article-3-4.md) | +| `x-like-posts` | 自适应长图 | 900px 宽 | X 风格帖子分享长图 | [references/x-like-posts.md](references/x-like-posts.md) | +| `wechat-cover-split` | 335:100 | 1340×400 | 公众号文章封面长条图(左标题右 icon) | [references/wechat-cover-split.md](references/wechat-cover-split.md) | + +## 新增模板 + +1. 新建 `assets/templates/.html` +2. 在 `render_card.py` 的 `size_map` 里注册尺寸 +3. 在上方模板索引中添加一行 +4. 创建对应 `references/.md`,记录该模板的参数、字数上限、配图选取规则 diff --git a/skills_developing/z-card-image/_meta.json b/skills_developing/z-card-image/_meta.json new file mode 100644 index 0000000..d82f585 --- /dev/null +++ b/skills_developing/z-card-image/_meta.json @@ -0,0 +1,6 @@ +{ + "ownerId": "kn7f5x5z4a9avydehdqe6px1598222s5", + "slug": "z-card-image", + "version": "1.1.0", + "publishedAt": 1773287088721 +} \ No newline at end of file diff --git a/skills_developing/z-card-image/assets/fonts/AlimamaShuHeiTi-Bold.ttf b/skills_developing/z-card-image/assets/fonts/AlimamaShuHeiTi-Bold.ttf new file mode 100644 index 0000000..4c9f3cc Binary files /dev/null and b/skills_developing/z-card-image/assets/fonts/AlimamaShuHeiTi-Bold.ttf differ diff --git a/skills_developing/z-card-image/assets/icons/clover.svg b/skills_developing/z-card-image/assets/icons/clover.svg new file mode 100644 index 0000000..0af3247 --- /dev/null +++ b/skills_developing/z-card-image/assets/icons/clover.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/skills_developing/z-card-image/assets/icons/jinx-face.svg b/skills_developing/z-card-image/assets/icons/jinx-face.svg new file mode 100644 index 0000000..5ee8e1d --- /dev/null +++ b/skills_developing/z-card-image/assets/icons/jinx-face.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills_developing/z-card-image/assets/icons/openclaw-lobster.svg b/skills_developing/z-card-image/assets/icons/openclaw-lobster.svg new file mode 100644 index 0000000..d112015 --- /dev/null +++ b/skills_developing/z-card-image/assets/icons/openclaw-lobster.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills_developing/z-card-image/assets/icons/openclaw-logo.svg b/skills_developing/z-card-image/assets/icons/openclaw-logo.svg new file mode 100644 index 0000000..4aff6b2 --- /dev/null +++ b/skills_developing/z-card-image/assets/icons/openclaw-logo.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills_developing/z-card-image/assets/icons/zzclub-logo-gray.svg b/skills_developing/z-card-image/assets/icons/zzclub-logo-gray.svg new file mode 100644 index 0000000..ad088bd --- /dev/null +++ b/skills_developing/z-card-image/assets/icons/zzclub-logo-gray.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/skills_developing/z-card-image/assets/styles/md.css b/skills_developing/z-card-image/assets/styles/md.css new file mode 100644 index 0000000..0fcc888 --- /dev/null +++ b/skills_developing/z-card-image/assets/styles/md.css @@ -0,0 +1,76 @@ +/* + * md.css — Markdown 内容渲染样式 + * 用于 article-3-4 模板,供用户自定义覆盖。 + * + * 注意:{{HIGHLIGHT_COLOR}} 由脚本在运行时内联替换到模板 + + + + + + + + +
+ + {{TITLE}} + {{PAGE_LABEL}} +
+
+ +
+ {{CONTENT_HTML}} +
+ + + + diff --git a/skills_developing/z-card-image/assets/templates/poster-3-4.html b/skills_developing/z-card-image/assets/templates/poster-3-4.html new file mode 100644 index 0000000..292b883 --- /dev/null +++ b/skills_developing/z-card-image/assets/templates/poster-3-4.html @@ -0,0 +1,102 @@ + + + + + + + + +
+ icon +
+
+
{{MAIN_TEXT_LINE1}}
+
{{MAIN_TEXT_LINE2}}
+
{{MAIN_TEXT_LINE3}}
+
+ +
+ Jinx + ✦ {{FOOTER_TEXT}} +
+ + diff --git a/skills_developing/z-card-image/assets/templates/tweet-thread.html b/skills_developing/z-card-image/assets/templates/tweet-thread.html new file mode 100644 index 0000000..754269f --- /dev/null +++ b/skills_developing/z-card-image/assets/templates/tweet-thread.html @@ -0,0 +1,238 @@ + + + + + + + + +
+
+
+ + {{HEADER_LABEL}} +
+

Tweet Thread

+
+ avatar +
+ {{AUTHOR_NAME}} + {{AUTHOR_HANDLE}} +
+
+
+ +
+ {{TWEET_ITEMS}} +
+ +
+
+ + {{FOOTER_TEXT}} +
+ Minimal Twitter-style export +
+
+ + diff --git a/skills_developing/z-card-image/assets/templates/wechat-cover-split.html b/skills_developing/z-card-image/assets/templates/wechat-cover-split.html new file mode 100644 index 0000000..55d959a --- /dev/null +++ b/skills_developing/z-card-image/assets/templates/wechat-cover-split.html @@ -0,0 +1,155 @@ + + + + + + + + +
+
+
{{FOOTER_TEXT}}
+
+
{{MAIN_TEXT_LINE1}}
+
{{MAIN_TEXT_LINE2}}
+
+ +
+ + +
+ + + + diff --git a/skills_developing/z-card-image/assets/templates/x-like-posts.html b/skills_developing/z-card-image/assets/templates/x-like-posts.html new file mode 100644 index 0000000..c892cb9 --- /dev/null +++ b/skills_developing/z-card-image/assets/templates/x-like-posts.html @@ -0,0 +1,246 @@ + + + + + + + + +
+
+
+ + {{HEADER_LABEL}} +
+

X-like Posts

+
+ avatar +
+ {{AUTHOR_NAME}} + {{AUTHOR_HANDLE}} +
+
+
{{TIME_RANGE_LABEL}}
+
+ +
+ {{TWEET_ITEMS}} +
+ +
+
+ + {{FOOTER_TEXT}} +
+ X-like share export +
+
+ + diff --git a/skills_developing/z-card-image/references/article-3-4.md b/skills_developing/z-card-image/references/article-3-4.md new file mode 100644 index 0000000..4ac195b --- /dev/null +++ b/skills_developing/z-card-image/references/article-3-4.md @@ -0,0 +1,73 @@ +# article-3-4 模板规范 + +## 模板信息 + +- 比例:3:4 +- 尺寸:900 × 1200px +- 用途:长文章分页卡片,适合公众号/小红书多图传播 + +## LLM 分段公式 + +使用此模板前,**LLM 需先自行计算分页**,不依赖脚本机械切分。 + +### 容量估算 + +``` +内容区有效高度 ≈ 1200 - 顶栏100 - 分割线50 - 底栏80 - 上下padding120 = 850px + +字号:30px +行高:1.85 → 每行实际高度 ≈ 30 × 1.85 = 55px +内容区有效宽度 = 900 - 左右padding144 = 756px + +每行可放中文字符 ≈ 756 / 30 = 25 个 +每页可放行数 ≈ 850 / 55 = 15 行 +每页中文字符容量 ≈ 25 × 15 = 375 字 + +英文字符宽度约为中文的 0.5,折算:2 英文字符 ≈ 1 中文字符 +段落间距约消耗 24px ≈ 0.5 行,每个段落边界扣 0.5 行 +``` + +### LLM 分段步骤 + +1. 统计全文总中文当量字符数(英文/数字按 0.5 折算) +2. 计算估算页数 = ceil(总字符数 / 340)(预留 10% 余量给段落间距) +3. 按**语义边界**切分,优先在段落结尾处分页,不截断句子 +4. 每页内容整理成纯文本(段落间空行分隔) +5. 将各页文本按顺序传入脚本,脚本负责渲染 + +### 示例 + +全文 800 中文当量字符 → 估算 ceil(800/340) = 3 页 → 按语义切成 3 段 + +## 脚本参数 + +```bash +python3 render_article.py \ + --title "文章标题" \ + --text "该页正文内容(纯文本,段落间空行分隔)" \ + --page-num 1 \ + --page-total 3 \ + --out /path/to/workspace/tmp/card_01.png \ + [--highlight "#22a854"] \ + [--bg "#e6f5ef"] \ + [--footer "公众号 · 早早集市"] +``` + +> 注意:LLM 调用时每页单独调用一次脚本,传入该页文本和对应页码。 + +## 字数上限 + +每页建议不超过 340 中文当量字符(脚本实际执行时取 90% = 306 字作为安全上限,宁少勿多)。 + +脚本分段规则(无需 LLM 介入): +1. 优先在段落边界处分页 +2. 段落过长时,在句子结束符(。!?…)处截断 +3. 找不到句末符,退而在逗号/分号处截 +4. 实在没有分隔符,截到安全上限的 85% + +## 水印/底栏 + +- 默认:`公众号 · 早早集市` +- 小红书:`小红书 · 阿康` +- 非最后一页自动显示「← 滑动查看更多」 +- 最后一页显示「· 全文完」 diff --git a/skills_developing/z-card-image/references/poster-3-4.md b/skills_developing/z-card-image/references/poster-3-4.md new file mode 100644 index 0000000..90c95e4 --- /dev/null +++ b/skills_developing/z-card-image/references/poster-3-4.md @@ -0,0 +1,80 @@ +# image-3-4 模板规范 + +比例:3:4 | 尺寸:900×1200 | 用途:小绿书 / 公众号 / 小红书通用封面 + +## 渲染命令 + +```bash +python3 skills/z-card-image/scripts/render_card.py \ + --template poster-3-4 \ + --out tmp/card.png \ + --line1 "OpenClaw" \ + --line2 "有两层" \ + --line3 "model 配置" \ + --highlight "#22a854" \ + --bg "#e6f5ef" \ + --footer "公众号 · 你的名字" \ + --icon {agent_dir}/dataset/icon.[png/jpg] +``` + +## 参数说明 + +| 参数 | 默认值 | 说明 | +|------|--------|------| +| `--line1` | 空 | 第一行文字 | +| `--line2` | 空 | 第二行文字(空则隐藏)| +| `--line3` | 空 | 第三行文字(空则隐藏)| +| `--hl1` | 关闭 | 整行高亮:第一行 | +| `--hl2` | 关闭 | 整行高亮:第二行 | +| `--hl3` | 关闭 | 整行高亮:第三行 | +| `--highlight-words` | 空 | 按词高亮,逗号分隔,如 `OpenClaw,GPT-5.4`(跨行生效)| +| `--highlight` | `#22a854` | 高亮/强调色 | +| `--bg` | `#e6f5ef` | 背景色 | +| `--footer` | `公众号 · 早早集市` | 底部文字 | +| `--icon` | 自动判断 | 顶部图标路径,不传则按内容自动选 | + +## icon + +默认使用: + +`{agent_dir}/dataset/icon.[png/jpg]` + +显式传 `--icon {agent_dir}/dataset/icon.[png/jpg]` 。 + +## 高亮文字处理规则 + +**两种高亮方式,按需选择:** + +### 1. 整行高亮(`--hl1` / `--hl2` / `--hl3`) +整行文字渲染为高亮色。适合"某一行是关键句"的场景。 + +> 示例:第二行要高亮 +> → `--line1 "GPT-5.4 发布了" --line2 "能控电脑" --hl2` + +### 2. 按词高亮(`--highlight-words`) +在任意行中,将指定词语渲染为高亮色,其余文字保持黑色。适合"某几个关键词"的场景。 + +> 示例:高亮 OpenClaw 和 GPT-5.4 +> → `--line1 "GPT-5.4 最适合" --line2 "OpenClaw 使用" --highlight-words "GPT-5.4,OpenClaw"` + +**优先级规则(有歧义时):** +- 用户说「高亮第X行」→ 用 `--hlX` +- 用户说「高亮某个词/某几个词」→ 用 `--highlight-words` +- 两者可以同时使用 +- 若用户无明确要求,默认将最关键词/句用 `--highlight-words` 标出 + + + +| 位置 | 最多字数 | 说明 | +|------|---------|------| +| 每行(line1/2/3) | **6~7 个汉字** / **10~12 个英文字符** | 字号 108px,可用宽 720px | +| 三行合计 | **≤ 20 字** | 超出则横向溢出,无法使用 | + +> 用户文案超出时,先帮忙拆分/缩写到上限内,再渲染,不要直接塞入模板。 + +## 配图选取原则 + +| 条件 | 使用图标 | 文件 | +|------|---------|------| +| 任意行含 `openclaw`(不区分大小写) | OpenClaw 小龙虾 | `assets/icons/openclaw-logo.svg` | +| 其他(默认) | 博客站 logo(灰色) | `assets/icons/zzclub-logo-gray.svg` | diff --git a/skills_developing/z-card-image/references/tweet-thread.md b/skills_developing/z-card-image/references/tweet-thread.md new file mode 100644 index 0000000..61461c1 --- /dev/null +++ b/skills_developing/z-card-image/references/tweet-thread.md @@ -0,0 +1,14 @@ +# tweet-thread 兼容说明 + +`tweet-thread` 已升级为更通用的 `x-like-posts` 路线。 + +请改读: + +[references/x-like-posts.md](x-like-posts.md) + +兼容规则: + +- 新正式脚本是 `render_x_like_posts.py` +- `render_tweet_thread.py` 仅保留兼容包装 +- 旧参数 `--tweet` / `--tweets-file` 仍可继续使用 +- 新推荐参数是 `--post` / `--posts-file` diff --git a/skills_developing/z-card-image/references/wechat-cover-split.md b/skills_developing/z-card-image/references/wechat-cover-split.md new file mode 100644 index 0000000..6ebd7c0 --- /dev/null +++ b/skills_developing/z-card-image/references/wechat-cover-split.md @@ -0,0 +1,81 @@ +# wechat-cover-split 模板规范 + +比例:`335:100`(左 `2.35:1` + 右 `1:1`) | 尺寸:1340×400 | 用途:公众号文章封面图 + +> 该模板渲染时会额外调用 `ffmpeg` 做顶部精确裁切,以适配 Chrome 在短横幅截图下的视口偏差。 + +## 适用场景 + +- 用户说"公众号文章封面图" +- 用户说"微信公众号头图 / 封面长图" +- 需要一张图同时切出左侧横图和右侧方图 + +## 切图规则 + +整张图由两部分组成: + +- 左侧:`940×400`,比例 `2.35:1`,放标题文案 +- 右侧:`400×400`,比例 `1:1`,放 icon + +如果业务端需要拆图使用: + +- 左图:取左侧 `940×400` +- 右图:取右侧 `400×400` + +## 渲染命令 + +```bash +python3 skills/z-card-image/scripts/render_card.py \ + --template wechat-cover-split \ + --out tmp/wechat-cover.png \ + --line1 "OpenAI 收购 Promptfoo" \ + --line2 "意味着什么" \ + --highlight "#22a854" \ + --bg "#eef7f2" \ + --footer "公众号 · 你的名字" \ + --icon {agent_dir}/dataset/icon.[png|jpg] +``` + +## 参数说明 + +| 参数 | 默认值 | 说明 | +|------|--------|------| +| `--line1` | 空 | 第一行标题 | +| `--line2` | 空 | 第二行标题 | +| `--line3` | 空 | 预留;该模板会自动并入第二行,不单独显示 | +| `--hl1/hl2/hl3` | 关闭 | 整行高亮 | +| `--highlight-words` | 空 | 按词高亮 | +| `--highlight` | `#22a854` | 强调色 | +| `--bg` | `#e6f5ef` | 背景色 | +| `--footer` | `公众号 · 你的名字` | 公众号名称 | +| `--icon` | 默认 logo | 右侧 1:1 区域 icon | + +## icon + +默认使用: + +`{agent_dir}/dataset/icon.[png/jpg]` + +显式传 `--icon {agent_dir}/dataset/icon.[png/jpg]` 。 + +## 字数限制 + +这个模板本质上仍是"大字报"风格,文案不能长: + +| 位置 | 建议字数 | 说明 | +|------|---------|------| +| 单行 | 12~18 个汉字 | 优先单行呈现 | +| 总行数 | ≤ 2 行 | 超出会明显破坏长条图视觉 | +| 总字数 | ≤ 28 个汉字 | 再长应先缩写或拆标题 | + +优先做法: + +1. 先抽标题主干 +2. 优先单行,必要时拆成 2 行 +3. 关键词可用整行或按词高亮 + +## 使用规则 + +- 用户明确说"公众号文章封面图"时,优先用本模板 +- 用户只是要常规封面 / 金句图,仍使用 `poster-3-4` +- **强规则:公众号文章封面图必须至少有一个高亮词**(`--highlight-words` 或 `--hl1/hl2/hl3`),不可省略 diff --git a/skills_developing/z-card-image/references/x-like-posts.md b/skills_developing/z-card-image/references/x-like-posts.md new file mode 100644 index 0000000..b307541 --- /dev/null +++ b/skills_developing/z-card-image/references/x-like-posts.md @@ -0,0 +1,140 @@ +# x-like-posts 模板规范 + +比例:自适应长图 | 宽度:900px | 用途:将多条帖子整理成一张 X / Twitter 风格分享长图 + +## 适用场景 + +- 用户要“X 风格分享图” +- 用户给的是帖子类型数据,不一定来自 Twitter +- 用户希望保留 X 的阅读感,但以通用帖子分享图方式导出 + +## 渲染命令 + +```bash +python3 skills/z-card-image/scripts/render_x_like_posts.py \ + --author "OpenAI" \ + --handle "@OpenAI" \ + --post "第一条帖子内容" \ + --post "第二条帖子内容" \ + --out tmp/x-like-posts.png +``` + +如帖子较多,优先写入 JSON 文件后传入: + +```bash +python3 skills/z-card-image/scripts/render_x_like_posts.py \ + --author "OpenAI" \ + --handle "@OpenAI" \ + --posts-file tmp/posts.json \ + --out /absolute/path/to/output/x-like-posts.png +``` + +## JSON 输入格式 + +`--posts-file` 读取一个 JSON 数组,支持两种结构。 + +### 1. 字符串数组 + +最简格式,每个元素只提供正文: + +```json +[ + "post 1", + "post 2" +] +``` + +此格式下模板只显示: + +- 正文 +- 外层参数提供的作者名 / handle / avatar + +### 2. 对象数组 + +推荐格式,每条帖子可带时间和互动信息: + +```json +[ + { + "text": "post 1", + "created_at": "2026-03-11T04:39:46.000Z", + "url": "https://x.com/foo/status/1", + "favorite_count": 31, + "retweet_count": 4 + }, + { + "text": "post 2" + } +] +``` + +字段规则: + +| 字段 | 必填 | 说明 | 当前是否显示 | +|------|------|------|--------------| +| `text` | 是 | 帖子正文 | 是 | +| `created_at` | 否 | 帖子发布时间,建议 ISO 8601 | 是 | +| `url` | 否 | 原帖链接 | 是(显示为 `x.com` 标记) | +| `favorite_count` | 否 | 点赞数 | 是 | +| `retweet_count` | 否 | 转发 / repost 数 | 是 | + +### 当前模板显示逻辑 + +- `text`:显示为正文 +- `created_at`:显示在每条帖子底部,并用于顶部日期标签 +- `favorite_count`:大于 0 时显示 +- `retweet_count`:大于 0 时显示 +- `url`:存在时显示 `x.com` 来源标记 +- 作者名、handle、avatar:不从 JSON 内层读取,而是由外层参数 `--author`、`--handle`、`--avatar` 控制 + +### 兼容说明 + +- 旧参数 `--tweet` / `--tweets-file` 仍可用 +- 旧命名 `tweet-thread` 已并入 `x-like-posts` +- 如果输入来自 `ingest-service`,优先使用它的 `created_at` 作为发布时间 + +## 参数说明 + +| 参数 | 默认值 | 说明 | +|------|--------|------| +| `--author` | `Unknown Author` | 作者名 | +| `--handle` | `@twitter` | 作者 handle | +| `--post` | 空 | 单条帖子内容,可重复传多次 | +| `--posts-file` | 空 | 帖子 JSON 文件 | +| `--out` | 必填 | 输出 PNG 路径,支持相对路径或绝对路径 | +| `--header-label` | `X-like 帖子分享图` | 顶部说明文案 | +| `--footer` | `整理转发 · via z-card-image` | 底部来源文案 | +| `--bg` | `#f5f8fa` | 页面背景 | +| `--card-bg` | `#ffffff` | 卡片背景 | +| `--text` | `#0f1419` | 正文颜色 | +| `--muted` | `#536471` | 次级文字颜色 | +| `--border` | `#e6ecf0` | 分隔线颜色 | +| `--accent` | `#1d9bf0` | 强调色 | +| `--avatar` | 默认头像 | 作者头像 | + +## 内容处理规则 + +1. 一条帖子对应一个卡片区块,按输入顺序排列 +2. 保留段落换行;空行只用于分段,不额外显示 +3. 多条帖子合成一张长图,画布高度按内容长度自动估算 +4. 若帖子过多导致总高度接近上限,优先按语义拆成多张图,不要强行塞满一张 +5. 时间信息默认取帖子 `created_at`;顶部只显示日期,每条帖子底部显示单条发布时间 + +## 时间规则 + +- 时间字段优先使用 `created_at` +- 不使用 `first_seen_at` 作为新闻发布时间,它只是 ingest 入库时间 +- 当前模板按 `Asia/Shanghai (UTC+8)` 展示时间 +- 顶部只显示日期 `YYYY-MM-DD` +- 每条帖子底部显示精确到分钟的发布时间 + +## 导出规则 + +- 支持导出到具体位置:`--out` 可传相对路径或绝对路径 +- 脚本会自动创建目标目录 +- 如果后续还要通过消息工具发图,输出仍建议放在当前 workspace 内,避免系统临时目录上传失败 + +## 使用规则 + +- 用户明确提到“X 风格分享图 / 帖子分享图 / 帖子长图”时,优先使用本模板 +- 用户只是要一句金句封面图时,仍使用 `poster-3-4` diff --git a/skills_developing/z-card-image/scripts/render_article.py b/skills_developing/z-card-image/scripts/render_article.py new file mode 100644 index 0000000..c2dbb5c --- /dev/null +++ b/skills_developing/z-card-image/scripts/render_article.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +""" +render_article.py — 将文本渲染成单张 article-3-4 卡片图 + +LLM 负责分页逻辑,每页单独调用本脚本。 + +用法(单页模式,推荐): + python3 render_article.py \ + --title "文章标题" \ + --text "该页正文(段落间空行分隔)" \ + --page-num 1 \ + --page-total 3 \ + --out /path/to/workspace/tmp/card_01.png \ + [--highlight "#22a854"] \ + [--bg "#e6f5ef"] \ + [--footer "公众号 · 早早集市"] + +用法(批量模式,兼容保留): + python3 render_article.py \ + --title "文章标题" \ + --text "全文..." \ + --out-dir /path/to/output \ + [--chars-per-page 280] +""" + +import argparse, shutil, subprocess, sys, tempfile, re +from html import escape +from pathlib import Path + +SKILL_DIR = Path(__file__).parent.parent +TEMPLATE_PATH = SKILL_DIR / "assets" / "templates" / "article-3-4.html" +MD_CSS_PATH = SKILL_DIR / "assets" / "styles" / "md.css" +ICONS_DIR = SKILL_DIR / "assets" / "icons" +FONTS_DIR = SKILL_DIR / "assets" / "fonts" + +CHROME_PATHS = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "google-chrome", + "chromium", +] + +W, H = 900, 1200 +CHARS_PER_PAGE = 280 + + +def find_chrome(): + for p in CHROME_PATHS: + if Path(p).exists() or shutil.which(p): + return p + return None + + +def split_at_sentence_boundary(text: str, limit: int) -> tuple: + """ + 在 limit 字符以内,找最后一个句子结束符(。!?…)处截断。 + 宁少勿多:找不到就在最后一个逗号/分号处截,再找不到就硬截到 limit*0.85。 + 返回 (taken, rest) + """ + if len(text) <= limit: + return text, '' + + # 在 limit 范围内从后往前找强句末 + strong_ends = set('。!?…\n') + weak_ends = set(',;,.;') + + candidate = -1 + for i in range(min(limit, len(text)) - 1, max(limit // 2, 0) - 1, -1): + if text[i] in strong_ends: + candidate = i + 1 + break + + if candidate == -1: + # 找弱分隔符 + for i in range(min(limit, len(text)) - 1, max(limit // 2, 0) - 1, -1): + if text[i] in weak_ends: + candidate = i + 1 + break + + if candidate == -1: + # 实在没有,保守截到 85% + candidate = int(limit * 0.85) + + return text[:candidate].strip(), text[candidate:].strip() + + +def split_text_into_pages(text: str, chars_per_page: int) -> list: + """ + 按段落优先、句子边界兜底的分页逻辑。 + 宁少勿多:每页预留 10% buffer,不塞满。 + """ + safe_limit = int(chars_per_page * 0.9) # 保守上限 + paragraphs = [p.strip() for p in re.split(r'\n{2,}', text.strip()) if p.strip()] + + pages = [] + current_chunks = [] + current_len = 0 + + for para in paragraphs: + # 超长段落先按句子边界切碎 + while len(para) > safe_limit: + taken, para = split_at_sentence_boundary(para, safe_limit) + if current_chunks: + pages.append(current_chunks) + current_chunks = [] + current_len = 0 + pages.append([taken]) + + if not para: + continue + + # 加入当前页会不会超限 + if current_len + len(para) > safe_limit and current_chunks: + pages.append(current_chunks) + current_chunks = [] + current_len = 0 + + current_chunks.append(para) + current_len += len(para) + + if current_chunks: + pages.append(current_chunks) + + return pages + + +def text_to_html(text: str) -> str: + """把文本整体交给 markdown 渲染,支持完整 MD 语法""" + try: + import markdown as md_lib + except ImportError: + sys.exit('需要安装 markdown 库:pip install markdown') + return md_lib.markdown(text, extensions=['fenced_code', 'tables', 'nl2br']) + + +def md_to_html(text: str) -> str: + """把 Markdown 转成 HTML 片段,需要 pip install markdown""" + try: + import markdown + return markdown.markdown(text, extensions=['fenced_code', 'tables', 'nl2br']) + except ImportError: + sys.exit('需要安装 markdown 库:pip install markdown') + + +def render_page(chrome, tpl, out_path, title, content_html, page_label, bottom_tip, + highlight, bg, footer, icon_path, avatar_path, font_path, md_css_path=''): + html = tpl + replacements = { + '{{MD_CSS_PATH}}': str(md_css_path) if md_css_path else '', + '{{TITLE}}': escape(title), + '{{CONTENT_HTML}}': content_html, + '{{PAGE_LABEL}}': escape(page_label), + '{{BOTTOM_TIP}}': escape(bottom_tip), + '{{HIGHLIGHT_COLOR}}': highlight, + '{{BG_COLOR}}': bg, + '{{FOOTER_TEXT}}': escape(footer), + '{{ICON_PATH}}': icon_path, + '{{AVATAR_PATH}}': avatar_path, + '{{FONT_PATH}}': font_path, + } + for k, v in replacements.items(): + html = html.replace(k, v) + + with tempfile.NamedTemporaryFile(suffix='.html', delete=False, mode='w', encoding='utf-8') as f: + f.write(html) + tmp_html = f.name + + cmd = [ + chrome, '--headless', '--disable-gpu', '--no-sandbox', + f'--screenshot={out_path}', + f'--window-size={W},{H}', + f'file://{tmp_html}', + ] + result = subprocess.run(cmd, capture_output=True) + Path(tmp_html).unlink(missing_ok=True) + if result.returncode != 0: + sys.exit(f'Chrome failed:\n{result.stderr.decode()}') + print(f'✅ {out_path}') + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--title', required=True) + ap.add_argument('--text', default='') + ap.add_argument('--text-file', default='') + # 单页模式 + ap.add_argument('--page-num', type=int, default=0) + ap.add_argument('--page-total', type=int, default=0) + ap.add_argument('--out', default='') + # 批量模式 + ap.add_argument('--out-dir', default='') + ap.add_argument('--chars-per-page', type=int, default=CHARS_PER_PAGE) + ap.add_argument('--md', action='store_true', help='输入为 Markdown,自动转 HTML 渲染') + # 样式 + ap.add_argument('--highlight', default='#3d6b4f') + ap.add_argument('--bg', default='#f9fcfa') + ap.add_argument('--footer', default='公众号 · 早早集市') + ap.add_argument('--icon', default='') + args = ap.parse_args() + + if args.text_file: + text = Path(args.text_file).read_text(encoding='utf-8') + elif args.text: + text = args.text + else: + sys.exit('需要 --text 或 --text-file') + + chrome = find_chrome() + if not chrome: + sys.exit('Chrome/Chromium not found') + + # MD 和纯文本共用同一模板,都走 markdown 渲染 + tpl = TEMPLATE_PATH.read_text(encoding='utf-8') + icon_path = args.icon or str(ICONS_DIR / 'zzclub-logo-gray.svg') + avatar_path = str(ICONS_DIR / 'avatar_jinx_cartoon.jpg') + font_path = str(FONTS_DIR / 'AlimamaShuHeiTi-Bold.ttf') + md_css_path = str(MD_CSS_PATH) + + def to_content_html(t: str) -> str: + return text_to_html(t) # text_to_html 已内置 markdown 渲染 + + # 单页模式 + if args.page_num > 0 and args.out: + page_total = args.page_total if args.page_total > 0 else args.page_num + page_label = f'{args.page_num} / {page_total}' + bottom_tip = '· 全文完' if args.page_num == page_total else '← 滑动查看更多' + render_page( + chrome=chrome, tpl=tpl, out_path=Path(args.out), + title=args.title, content_html=to_content_html(text), + page_label=page_label, bottom_tip=bottom_tip, + highlight=args.highlight, bg=args.bg, footer=args.footer, + icon_path=icon_path, avatar_path=avatar_path, font_path=font_path, + md_css_path=md_css_path, + ) + return + + # 批量模式 + if not args.out_dir: + sys.exit('需要 --out (单页模式) 或 --out-dir (批量模式)') + + pages = split_text_into_pages(text, args.chars_per_page) + total = len(pages) + print(f'共 {total} 页') + out_dir = Path(args.out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + for i, chunks in enumerate(pages, 1): + parts = [] + for chunk in chunks: + c = escape(chunk) + c = re.sub(r'\*\*(.+?)\*\*', r'\1', c) + parts.append(f'

{c}

') + content_html = '\n'.join(parts) + page_label = f'{i} / {total}' + bottom_tip = '· 全文完' if i == total else '← 滑动查看更多' + render_page( + chrome=chrome, tpl=tpl, out_path=out_dir / f'card_{i:02d}.png', + title=args.title, content_html=content_html, + page_label=page_label, bottom_tip=bottom_tip, + highlight=args.highlight, bg=args.bg, footer=args.footer, + icon_path=icon_path, avatar_path=avatar_path, font_path=font_path, + ) + print(f'\n🎉 完成,共输出 {total} 张图到 {out_dir}') + + +if __name__ == '__main__': + main() diff --git a/skills_developing/z-card-image/scripts/render_card.py b/skills_developing/z-card-image/scripts/render_card.py new file mode 100644 index 0000000..90ffa2a --- /dev/null +++ b/skills_developing/z-card-image/scripts/render_card.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +""" +render_card.py — 用模板渲染卡片图,Chrome headless 截图输出 PNG + +用法: + python3 render_card.py \ + --template poster-3-4 \ + --out /tmp/card.png \ + --line1 "OpenClaw" \ + --line2 "有两层" \ + --line3 "model 配置" \ + --highlight "#22a854" \ + --bg "#e6f5ef" \ + --footer "公众号 · 早早集市" + +模板位于 assets/templates/