Skill: caoyacheng/ai-news-poster (version 1.0.0)

--- name: ai-news-poster description: Generate fixed-template daily AI news posters from five news items. Use when the user asks to create a poster, social card, or image summary for "today's AI news", "5 AI news", "AI headlines", "每日AI资讯", or "AI新闻海报", including Chinese or bilingual output. --- # AI News Poster Create one fixed-style poster from exactly 5 AI news items. ## When to use this skill Use this skill when the user wants: - A daily AI news poster - A fixed template visual for 5 AI headlines - A Chinese or bilingual social poster summarizing AI updates If the user asks for a long-form report, article, PPT, or spreadsheet, do not use this skill. ## Input contract Before generating the poster, normalize input into this structure: ```json { "date": "YYYY-MM-DD", "title": "今日AI资讯速览", "news": [ {"headline": "新闻1标题", "summary": "一句话摘要（18-32字）", "source": "来源", "tag": "模型/产品/融资/政策/研究"}, {"headline": "新闻2标题", "summary": "一句话摘要（18-32字）", "source": "来源", "tag": "模型/产品/融资/政策/研究"}, {"headline": "新闻3标题", "summary": "一句话摘要（18-32字）", "source": "来源", "tag": "模型/产品/融资/政策/研究"}, {"headline": "新闻4标题", "summary": "一句话摘要（18-32字）", "source": "来源", "tag": "模型/产品/融资/政策/研究"}, {"headline": "新闻5标题", "summary": "一句话摘要（18-32字）", "source": "来源", "tag": "模型/产品/融资/政策/研究"} ], "footer": "数据来源: 公开新闻整理", "brand": "你的品牌名" } ``` Rules: - Always use exactly 5 news items. - If more than 5 are provided, keep the most important 5 and explain filtering in one sentence. - If fewer than 5 are provided, ask for missing items. - Remove hype and keep each summary factual. ## Fixed poster template (must follow) ### Canvas - Size: 1080 x 1350 (4:5) - Background: deep navy gradient (`#0B1020` to `#121A33`) - Safe margin: 64px on all sides ### Typography - Main title: 72px bold, white - Date subtitle: 32px medium, `#A9B4D0` - News headline: 38px semibold, white - News summary: 28px regular, `#D6DEFF` - Meta line (tag + source): 24px medium, `#8FA2D8` - Footer: 22px regular, `#93A0C3` ### Layout 1. Top block: - Main title (`今日AI资讯速览`) - Date line (`YYYY-MM-DD`) 2. Body block: - 5 cards in a single vertical column, evenly spaced - Each card includes: - Index badge (`01` to `05`) - Headline (single line preferred, max 24 Chinese chars) - Summary (1 line, max 32 Chinese chars) - Meta line: `#tag | source` 3. Bottom block: - Left: footer text - Right: brand mark text ### Visual style - Card background: translucent white 8%-10% - Card border: 1px `#2B3C73` - Corner radius: 18px - Use one accent color for badges: `#6AA8FF` - Keep high contrast; avoid decorative clutter ## Generation workflow 1. Validate and normalize 5 news items. 2. Rewrite each summary to one concise sentence (18-32 Chinese chars). 3. Build poster JSON following the input contract. 4. Generate one final poster image using: - `python scripts/render.py <input.json> <output.png>` 5. If needed, install dependency once: - `python -m pip install pillow` 6. Self-check before final output: - Exactly 5 items - No overflow/cropping - Typography hierarchy is clear - Source shown for all items ## Utility scripts - Renderer: `scripts/render.py` - Sample input: `examples/input.sample.json` Quick start: - `python scripts/render.py examples/input.sample.json output/today-ai-news.png` Manual copy flow (fallback when script is unavailable): - Build poster copy using the exact order: - Title -> Date -> News01..News05 -> Footer -> Brand - Render one final poster image in 1080x1350. ## Poster text template Use this content skeleton before rendering: ```text [TITLE] 今日AI资讯速览 [DATE] 2026-02-28 [01] {headline} {summary} #{tag} | {source} [02] {headline} {summary} #{tag} | {source} [03] {headline} {summary} #{tag} | {source} [04] {headline} {summary} #{tag} | {source} [05] {headline} {summary} #{tag} | {source} [FOOTER] 数据来源: 公开新闻整理 [BRAND] {brand} ``` ## Output requirements - Primary output: one poster image file (`.png`) - Optional companion output: the normalized JSON used for rendering - Keep output deterministic: same input should produce same layout structure ## Example trigger prompts - "把今天 5 条 AI 新闻做成固定模版海报" - "按我们日更样式生成一张 AI 资讯图，给小红书发" - "Use today's five AI headlines and make a poster card"

path: OPENCLAW-操作手册.md
content: # ai-news-poster 在 OpenClaw 的操作手册

这份手册用于你下次独立完成安装、验证、测试与日常出图。

## 1) 前置条件

- 已安装 OpenClaw CLI（命令 `openclaw` 可用）
- 已有 skill 源目录：`/Users/cyc-mac/skills/skills/ai-news-poster`
- Python 3 可用（命令 `python3` 可用）
- 首次使用如缺依赖，安装 `Pillow`

```bash
python3 -m pip install pillow
```

## 2) 安装到 OpenClaw

OpenClaw 的本地托管技能目录是：`~/.openclaw/skills`

```bash
mkdir -p ~/.openclaw/skills
cp -R /Users/cyc-mac/skills/skills/ai-news-poster ~/.openclaw/skills/ai-news-poster
```

## 3) 安装后验证

### 3.1 查看技能详情

```bash
openclaw skills info ai-news-poster
```

预期关键结果：
- `Source: openclaw-managed`
- `Path: ~/.openclaw/skills/ai-news-poster/SKILL.md`
- 状态为 `Ready`

### 3.2 全量健康检查

```bash
openclaw skills check
```

预期关键结果：
- `ai-news-poster` 出现在 `Ready to use` 列表里

## 4) 功能测试（推荐方式）

直接从 OpenClaw 安装目录运行渲染脚本，验证“真实安装副本”能出图。

```bash
python3 ~/.openclaw/skills/ai-news-poster/scripts/render.py \
  ~/.openclaw/skills/ai-news-poster/examples/input.sample.json \
  ~/.openclaw/skills/ai-news-poster/output/openclaw-test.png
```

预期输出：
- 终端显示 `Poster generated: .../openclaw-test.png`
- 文件存在：`~/.openclaw/skills/ai-news-poster/output/openclaw-test.png`

## 5) 日常使用（每天出图）

### 5.1 准备当天新闻 JSON

复制模板：

```bash
cp ~/.openclaw/skills/ai-news-poster/examples/input.sample.json \
  ~/.openclaw/skills/ai-news-poster/examples/input.today.json
```

然后编辑 `input.today.json`：
- `date` 改为当天日期
- `news` 必须 **恰好 5 条**
- 每条包含：`headline` / `summary` / `source` / `tag`
- `brand` 改成你的品牌名

### 5.2 生成当天海报

```bash
python3 ~/.openclaw/skills/ai-news-poster/scripts/render.py \
  ~/.openclaw/skills/ai-news-poster/examples/input.today.json \
  ~/.openclaw/skills/ai-news-poster/output/today.png
```

## 6) 更新 skill（你改了仓库里的版本后）

如果你在仓库里改了 `skills/ai-news-poster`，重新覆盖到 OpenClaw 托管目录：

```bash
rm -rf ~/.openclaw/skills/ai-news-poster
cp -R /Users/cyc-mac/skills/skills/ai-news-poster ~/.openclaw/skills/ai-news-poster
openclaw skills info ai-news-poster
```

## 7) 常见问题排障

### Q1: `openclaw skills info ai-news-poster` 找不到
- 检查目录是否存在：`ls ~/.openclaw/skills/ai-news-poster`
- 确认 `SKILL.md` 在该目录下
- 再执行一次复制安装命令

### Q2: 渲染时报 `Pillow is required`
- 安装依赖：`python3 -m pip install pillow`

### Q3: 报错 `'news' must contain exactly 5 items`
- 必须严格 5 条新闻，不能多不能少

### Q4: 中文字体显示异常
- 当前脚本会自动尝试 macOS 常见中文字体
- 若你本机缺字体，可安装中文字体后重试

## 8) 快速检查清单（30 秒）

- [ ] `openclaw skills info ai-news-poster` 显示 `Ready`
- [ ] 输入 JSON 是 5 条新闻
- [ ] `python3 .../render.py input.json output.png` 成功
- [ ] 输出 PNG 存在且可打开



path: examples/input.sample.json
content: {
  "date": "2026-02-28",
  "title": "今日AI资讯速览",
  "news": [
    {
      "headline": "OpenAI发布新版推理模型能力更新",
      "summary": "新版本在复杂任务上提升稳定性并降低推理延迟。",
      "source": "OpenAI Blog",
      "tag": "模型"
    },
    {
      "headline": "Google扩展Gemini企业集成能力",
      "summary": "Workspace场景支持更多自动化流程与安全控制。",
      "source": "Google",
      "tag": "产品"
    },
    {
      "headline": "Anthropic公布新一轮开发者生态计划",
      "summary": "聚焦工具链支持与模型应用落地合作伙伴拓展。",
      "source": "Anthropic",
      "tag": "生态"
    },
    {
      "headline": "多家AI创业公司获新融资进展",
      "summary": "资本继续流向垂直场景与基础设施方向企业。",
      "source": "TechCrunch",
      "tag": "融资"
    },
    {
      "headline": "欧盟更新生成式AI治理讨论进程",
      "summary": "监管框架强调透明度与高风险应用责任边界。",
      "source": "EU News",
      "tag": "政策"
    }
  ],
  "footer": "数据来源: 公开新闻整理",
  "brand": "AI Daily"
}


path: scripts/render.py
content: #!/usr/bin/env python3
"""Render a fixed 5-item AI news poster to PNG.

Usage:
  python scripts/render.py examples/input.sample.json output.png
"""

from __future__ import annotations

import json
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any

try:
    from PIL import Image, ImageDraw, ImageFont
except ImportError as exc:  # pragma: no cover
    raise SystemExit(
        "Pillow is required. Install with: python -m pip install pillow"
    ) from exc


WIDTH = 1080
HEIGHT = 1350
MARGIN = 64
CARD_GAP = 16

BG_TOP = (112, 166, 230)
BG_BOTTOM = (96, 195, 240)
WHITE = (255, 255, 255)
TITLE_MAIN = (28, 70, 145)
TITLE_SUB = (48, 93, 171)
HEADLINE = (39, 81, 152)
SUMMARY = (49, 90, 163)
META = (72, 121, 192)
FOOTER = (221, 237, 255)
ACCENT = (109, 191, 242)
CARD_BORDER = (196, 224, 255, 255)
CARD_FILL = (250, 252, 255, 244)


@dataclass(frozen=True)
class NewsItem:
    headline: str
    summary: str
    source: str
    tag: str


@dataclass(frozen=True)
class PosterData:
    date: str
    title: str
    news: list[NewsItem]
    footer: str
    brand: str


def _safe_str(value: Any, field: str) -> str:
    if not isinstance(value, str):
        raise ValueError(f"'{field}' must be a string")
    text = value.strip()
    if not text:
        raise ValueError(f"'{field}' cannot be empty")
    return text


def load_input(path: Path) -> PosterData:
    raw = json.loads(path.read_text(encoding="utf-8"))
    if not isinstance(raw, dict):
        raise ValueError("Input root must be an object")

    date = _safe_str(raw.get("date"), "date")
    title = _safe_str(raw.get("title"), "title")
    footer = _safe_str(raw.get("footer"), "footer")
    brand = _safe_str(raw.get("brand"), "brand")

    items = raw.get("news")
    if not isinstance(items, list):
        raise ValueError("'news' must be a list")
    if len(items) != 5:
        raise ValueError("'news' must contain exactly 5 items")

    news: list[NewsItem] = []
    for idx, item in enumerate(items, start=1):
        if not isinstance(item, dict):
            raise ValueError(f"news[{idx}] must be an object")
        news.append(
            NewsItem(
                headline=_safe_str(item.get("headline"), f"news[{idx}].headline"),
                summary=_safe_str(item.get("summary"), f"news[{idx}].summary"),
                source=_safe_str(item.get("source"), f"news[{idx}].source"),
                tag=_safe_str(item.get("tag"), f"news[{idx}].tag"),
            )
        )

    return PosterData(date=date, title=title, news=news, footer=footer, brand=brand)


def pick_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
    candidates = [
        "/System/Library/Fonts/PingFang.ttc",
        "/System/Library/Fonts/Hiragino Sans GB.ttc",
        "/System/Library/Fonts/STHeiti Light.ttc",
        "/Library/Fonts/Arial Unicode.ttf",
        "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
        "/System/Library/Fonts/Supplemental/Arial.ttf",
    ]
    for font_path in candidates:
        try:
            return ImageFont.truetype(font_path, size=size, index=0)
        except OSError:
            continue
    return ImageFont.load_default()


def text_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont) -> float:
    return draw.textlength(text, font=font)


def truncate_to_width(
    draw: ImageDraw.ImageDraw,
    text: str,
    font: ImageFont.ImageFont,
    max_width: int,
) -> str:
    if text_width(draw, text, font) <= max_width:
        return text
    ellipsis = "..."
    low, high = 0, len(text)
    while low < high:
        mid = (low + high + 1) // 2
        trial = text[:mid].rstrip() + ellipsis
        if text_width(draw, trial, font) <= max_width:
            low = mid
        else:
            high = mid - 1
    return text[:low].rstrip() + ellipsis


def make_gradient(width: int, height: int, top: tuple[int, int, int], bottom: tuple[int, int, int]) -> Image.Image:
    image = Image.new("RGB", (width, height), top)
    px = image.load()
    for y in range(height):
        t = y / (height - 1)
        r = int(top[0] * (1 - t) + bottom[0] * t)
        g = int(top[1] * (1 - t) + bottom[1] * t)
        b = int(top[2] * (1 - t) + bottom[2] * t)
        for x in range(width):
            px[x, y] = (r, g, b)
    return image


def draw_tech_background(draw: ImageDraw.ImageDraw, width: int, height: int) -> None:
    # Soft grid and glow to mimic a light-tech poster style.
    for x in range(0, width, 54):
        draw.line((x, 0, x, height), fill=(180, 224, 255, 22), width=1)
    for y in range(0, height, 54):
        draw.line((0, y, width, y), fill=(180, 224, 255, 18), width=1)

    draw.ellipse((-180, -220, 460, 380), fill=(255, 255, 255, 64))
    draw.ellipse((width - 420, -180, width + 120, 320), fill=(175, 230, 255, 75))
    draw.ellipse((width - 260, height - 260, width + 120, height + 120), fill=(120, 210, 255, 54))

    # Decorative tiny stars.
    for x, y in ((64, 210), (180, 1150), (980, 240), (950, 1120), (820, 84)):
        draw.line((x - 6, y, x + 6, y), fill=(230, 245, 255, 180), width=2)
        draw.line((x, y - 6, x, y + 6), fill=(230, 245, 255, 180), width=2)


def wrap_lines(
    draw: ImageDraw.ImageDraw,
    text: str,
    font: ImageFont.ImageFont,
    max_width: int,
    max_lines: int,
) -> list[str]:
    if not text:
        return [""]
    lines: list[str] = []
    current = ""
    for ch in text:
        trial = current + ch
        if text_width(draw, trial, font) <= max_width:
            current = trial
            continue
        if current:
            lines.append(current)
        current = ch
        if len(lines) >= max_lines:
            break
    if len(lines) < max_lines and current:
        lines.append(current)
    if len(lines) > max_lines:
        lines = lines[:max_lines]
    if lines and len(lines) == max_lines and text_width(draw, lines[-1], font) > max_width:
        lines[-1] = truncate_to_width(draw, lines[-1], font, max_width)
    if len(lines) == max_lines and "".join(lines) != text:
        lines[-1] = truncate_to_width(draw, lines[-1] + "...", font, max_width)
    return lines


def draw_section_header(draw: ImageDraw.ImageDraw, x: int, y: int, w: int, text: str, font: ImageFont.ImageFont) -> None:
    draw.rounded_rectangle((x, y, x + w, y + 46), radius=12, fill=(97, 183, 238, 255))
    tw = text_width(draw, text, font)
    draw.text((x + (w - tw) / 2, y + 8), text, font=font, fill=(230, 248, 255))


def draw_news_card(
    draw: ImageDraw.ImageDraw,
    x: int,
    y: int,
    w: int,
    h: int,
    idx: int,
    item: NewsItem,
    number_font: ImageFont.ImageFont,
    headline_font: ImageFont.ImageFont,
    summary_font: ImageFont.ImageFont,
    meta_font: ImageFont.ImageFont,
) -> None:
    draw.rounded_rectangle((x, y, x + w, y + h), radius=16, fill=CARD_FILL, outline=CARD_BORDER, width=2)
    draw.text((x + 26, y + 20), f"{idx:02d}", font=number_font, fill=(44, 95, 170))

    text_x = x + 110
    text_w = w - 134
    headline_lines = wrap_lines(draw, item.headline, headline_font, text_w, max_lines=2)
    summary_lines = wrap_lines(draw, item.summary, summary_font, text_w, max_lines=3)
    meta_line = truncate_to_width(draw, f"#{item.tag} | {item.source}", meta_font, text_w)

    ty = y + 16
    for line in headline_lines:
        draw.text((text_x, ty), line, font=headline_font, fill=HEADLINE)
        ty += 34
    ty += 4
    for line in summary_lines:
        draw.text((text_x, ty), line, font=summary_font, fill=SUMMARY)
        ty += 28
    draw.text((text_x, y + h - 34), meta_line, font=meta_font, fill=META)


def render_poster(data: PosterData, output_path: Path, width: int = WIDTH, height: int = HEIGHT) -> None:
    base = make_gradient(width, height, BG_TOP, BG_BOTTOM).convert("RGBA")
    draw = ImageDraw.Draw(base, "RGBA")
    draw_tech_background(draw, width, height)

    title_font = pick_font(74, bold=True)
    sub_font = pick_font(30)
    section_font = pick_font(34, bold=True)
    number_font = pick_font(34, bold=True)
    headline_font = pick_font(30, bold=True)
    summary_font = pick_font(24)
    meta_font = pick_font(22)
    footer_font = pick_font(20)

    # Top title zone (close to reference style)
    draw.text((MARGIN + 48, 72), "科创中心", font=title_font, fill=TITLE_MAIN)
    draw.text((MARGIN + 48, 154), "AI热点速报", font=title_font, fill=TITLE_MAIN)
    draw.text((MARGIN + 58, 246), f"{data.date} 发布", font=sub_font, fill=TITLE_SUB)

    # Simple AI badge illustration on the right
    badge_x, badge_y = width - 300, 52
    draw.rounded_rectangle((badge_x, badge_y, badge_x + 210, badge_y + 220), radius=36, fill=(173, 226, 255, 95), outline=(226, 245, 255, 180), width=3)
    draw.rounded_rectangle((badge_x + 20, badge_y + 18, badge_x + 190, badge_y + 198), radius=30, fill=(195, 237, 255, 110), outline=(237, 250, 255, 190), width=2)
    draw.text((badge_x + 72, badge_y + 88), "AI", font=pick_font(62, bold=True), fill=(58, 122, 204))

    content_x = MARGIN
    content_w = width - MARGIN * 2

    # One section only: all five items
    y = 318
    draw_section_header(draw, content_x + 30, y, content_w - 60, "热点资讯", section_font)
    y += 60
    card_h = 158
    for idx in range(5):
        draw_news_card(
            draw,
            content_x,
            y,
            content_w,
            card_h,
            idx + 1,
            data.news[idx],
            number_font,
            headline_font,
            summary_font,
            meta_font,
        )
        y += card_h + CARD_GAP

    footer_y = height - 38
    draw.text((MARGIN, footer_y), data.footer, font=footer_font, fill=FOOTER)
    brand_w = text_width(draw, data.brand, footer_font)
    draw.text((width - MARGIN - brand_w, footer_y), data.brand, font=footer_font, fill=FOOTER)

    output_path.parent.mkdir(parents=True, exist_ok=True)
    base.convert("RGB").save(output_path, format="PNG")


def main() -> int:
    if len(sys.argv) < 3:
        print("Usage: python scripts/render.py <input.json> <output.png>", file=sys.stderr)
        return 1
    input_path = Path(sys.argv[1])
    output_path = Path(sys.argv[2])
    if not input_path.exists():
        print(f"Input file not found: {input_path}", file=sys.stderr)
        return 1

    try:
        data = load_input(input_path)
        render_poster(data, output_path)
    except Exception as exc:  # pragma: no cover
        print(f"Error: {exc}", file=sys.stderr)
        return 1

    print(f"Poster generated: {output_path}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (HIGH)