Skill: vinplezhang/vinplezhang-xiaohongshu-publisher (version 1.0.0)

--- name: xiaohongshu-publisher description: Draft and publish posts to 小红书 (Xiaohongshu/RED). Use when creating content for 小红书, drafting posts, generating cover images, or publishing via browser automation. Covers the full workflow from content creation to browser-based publishing, including cover image generation with Pillow. --- # 小红书 Publisher Create, format, and publish posts to 小红书 (Xiaohongshu/RED) through browser automation. ## Requirements - **Python 3** with **Pillow** (`pip install Pillow` or `apt install python3-pil`) - **CJK fonts** — `fonts-noto-cjk` on Linux (`apt install fonts-noto-cjk`), or Noto Sans CJK via Homebrew on macOS - **OpenClaw browser tool** — access to a browser with the user logged into 小红书 creator portal - A connected **OpenClaw node** (or sandbox browser) with Chrome/Chromium for browser automation ## Overview This skill handles the complete 小红书 publishing pipeline: 1. **Draft** — Write post content in 小红书 style 2. **Cover** — Generate a cover image (1080×1440) 3. **Review** — Send draft to user for approval via messaging channel 4. **Publish** — Use browser automation to post (or deliver for manual posting) ## Workflow ### Step 1: Draft Content Create post content following 小红书 style. See [references/content-guide.md](references/content-guide.md) for formatting rules, structure, and examples. Key rules: - Title ≤20 chars, with emoji hook - Use `---` section breaks, emoji bullets, short paragraphs - End with question CTA + hashtags (8-12 tags) - Save draft to `memory/xiaohongshu-draft.md` ### Step 2: Generate Cover Image Run the cover generation script: ```bash python3 <skill-dir>/scripts/gen_cover.py --title "主标题" --subtitle "副标题" --tags "标签1,标签2,标签3" --output /path/to/cover.png ``` Options: - `--title` — Main title (large text, required) - `--subtitle` — Subtitle line (medium text, optional) - `--tags` — Comma-separated feature tags shown as pills (optional) - `--badge` — Top-right badge text (default: "OpenClaw") - `--output` — Output path (default: `cover.png`) - `--gradient` — Color scheme: `purple` (default), `blue`, `green`, `orange`, `dark` Output: 1080×1440 PNG with gradient background, decorative elements, CJK text. ### Step 3: Send for Review Send the draft content + cover image to the user's messaging channel for review. Format: ``` 📝 小红书草稿 — [主题] 标题：[标题] [正文内容] 封面图已生成：[path] 请确认： ✅ 可以发布 ✏️ 需要修改 ❌ 不发 ``` **Never auto-publish.** Always wait for explicit user approval. ### Step 4: Publish via Browser After user approval, publish using browser automation on the configured node. See [references/browser-publish.md](references/browser-publish.md) for the complete browser automation steps. Summary: 1. Navigate to `https://creator.xiaohongshu.com/publish/publish` 2. Enter title and body text 3. Upload cover image via browser `upload` action 4. Click publish ### Fallback: Manual Publishing If browser automation is unavailable (CDP issues, node offline, etc.), send the complete post to the user's channel with all content formatted for easy copy-paste: ``` 📋 小红书发帖内容（请手动发布） 【标题】[标题] 【正文】 [正文内容] 【标签】[hashtags] 封面图：[path to cover image] ``` ## Known Limitations - **Mac Chrome CDP**: Chrome launched via SSH/node may fail to bind `--remote-debugging-port` on macOS (GUI session required). If `browser start` fails, fall back to manual publishing. - **Login state**: 小红书 creator portal requires login. If the session has expired, ask the user to re-login in their browser before proceeding. - **Pillow emoji**: Pillow cannot render color emoji (NotoColorEmoji.ttf) — use text/icon alternatives in cover images. ## Cron Integration This skill works with cron jobs for scheduled daily posting. Typical cron setup: ``` Schedule: 0 8 * * * (Asia/Shanghai) Session: isolated agentTurn Delivery: announce to user's channel ``` The cron job message should reference this skill and include the content plan/topic for the day.

path: _meta.json
content: ownerId: kn7f25fq88zj6cpv5zfby0s0q1811876
slug: vinplezhang-xiaohongshu-publisher
version: 1.0.0
publishedAt: 1770874380231

path: references/browser-publish.md
content: # Browser Publishing Guide

## Prerequisites

- A connected node with Chrome/Chromium, or a sandbox browser available
- User logged into 小红书 creator portal in the browser
- Browser tool configured with the appropriate profile and target for your setup

Example configuration (adjust to match your environment):
```
profile: openclaw
target: node (or sandbox)
node: <your-node-name>
```

## Pre-flight Check

Before attempting browser publish, verify:

```
1. Check node status: nodes → status
2. Test browser: browser → tabs (with your configured profile/target)
3. If tabs fails → fall back to manual publishing
```

## Publishing Steps

### 1. Navigate to Creator Portal

```
browser → navigate
  url: https://creator.xiaohongshu.com/publish/publish
```

Wait for page load, then snapshot to verify.

### 2. Check Login State

Snapshot the page. If redirected to login page:
- Notify user: "小红书需要重新登录，请在浏览器上登录后告诉我"
- Stop and wait for user confirmation

### 3. Enter Title

```
browser → snapshot (find title input field)
browser → act → click on title input
browser → act → type title text
```

Title input is typically the first text field on the publish page.

### 4. Enter Body Content

```
browser → snapshot (find body/content editor)
browser → act → click on content editor area
browser → act → type body text
```

The content editor is a rich text area. Type plain text — 小红书 handles basic formatting.

**Important**: The editor may use contenteditable div, not a standard input. Use snapshot to find the correct ref.

### 5. Upload Cover Image

Upload the cover image using browser automation:

```
browser → snapshot (find upload/image button)
browser → act → click upload area
browser → upload (paths: ["/path/to/cover.png"])
```

> **Note**: `setInputFiles` / `upload` may not trigger change events in all browser configurations. If the image doesn't appear after upload, ask the user to upload manually and provide the file path.

### 6. Publish

```
browser → snapshot (find publish/发布 button)
browser → act → click publish button
```

Wait and snapshot to verify success. Look for success message or redirect.

### 7. Verify

After publishing, navigate to the user's profile or the new post URL to confirm it's live.

## Troubleshooting

| Problem | Solution |
|---------|----------|
| CDP connection fails | Fall back to manual publishing via channel message |
| Login expired | Ask user to re-login in their browser |
| Page layout changed | Use snapshot + aria refs to find elements dynamically |
| Upload fails silently | Ask user to upload cover manually and provide the file path |
| Publish button grayed out | Check if required fields (title, content) are filled |

## Fallback: Manual Publishing

If any browser step fails, deliver all content formatted for manual copy-paste:

```
📋 小红书发帖内容（请手动发布）

【标题】标题文本

【正文】
完整正文内容...

【标签】#tag1 #tag2 ...

【封面图】/path/to/cover.png
```

Always prioritize getting content to the user over perfect automation.


path: references/content-guide.md
content: # 小红书 Content Guide

## Post Structure

Every post follows this structure:

```
[Emoji] [标题]（≤20字，吸睛）

开头段（1-2句话，设置场景/痛点）

---

[Emoji] 小标题一

内容段落（短句为主，3-5行）
▸ 要点用 ▸ 符号
▸ 代码用 ``` 包裹
▸ 关键词用 **加粗**

---

[Emoji] 小标题二
...

---

💡 总结/建议段

实用建议，1-3 条，编号列出

互动问题 + CTA 👇

#标签1 #标签2 #标签3 ...（8-12个）
```

## Writing Style Rules

1. **口语化** — 像跟朋友聊天，不是写论文。用「你」不用「您」
2. **短段落** — 每段 3-5 行，手机阅读友好
3. **Emoji 引导** — 每个大标题前放 emoji，但不要满屏 emoji
4. **技术但通俗** — 有深度但能让非专家看懂。专业术语要解释
5. **真实感** — 用亲身经历、实际截图、真实数据。不要泛泛而谈
6. **互动结尾** — 最后一句用问句，引导评论

## Title Formula

好标题 = Emoji + 数字/对比/悬念 + 关键词

Examples:
- 🔥 我是AI助手，一周帮程序员干了这些活
- 📱 手机秒变AI的眼睛和手脚
- 🔐 你的AI助手正在被投毒？
- 🧩 5分钟写一个AI能力包
- ⚡ 4个AI模型实测对比

## Section Separators

Use `---` between major sections. Within a section:
- ▸ for bullet points
- 1️⃣ 2️⃣ 3️⃣ for ordered steps
- **Bold** for key terms
- \`code\` for inline code
- ``` for code blocks (keep short, ≤5 lines if possible)

## Hashtag Strategy

Place at the very end. Include:
- 2-3 broad tags (#AI助手 #效率工具 #自动化)
- 2-3 product tags (#OpenClaw #ClaudeCode etc.)
- 2-3 topic-specific tags (related to the post content)
- 1-2 audience tags (#程序员 #开发者)

## Content Length

- Ideal: 800-1500 字
- Minimum: 500 字
- Maximum: 2000 字 (小红书 truncates long posts)

## Cover Image Text

Cover image should have:
- **Main title**: 2-4 大字，核心信息
- **Subtitle**: 1 行补充说明
- **Badge**: Series/brand identifier (e.g., "Day 6 | OpenClaw")
- **Feature tags**: 3-4 关键词 pills

Cover text should be different from (but related to) the post title — it's the visual hook.


path: scripts/gen_cover.py
content: #!/usr/bin/env python3
"""
Generate 小红书 cover image (1080x1440) with gradient background and CJK text.

Requirements:
  - python3, Pillow (pip install Pillow or apt install python3-pil)
  - CJK fonts: fonts-noto-cjk on Linux, or Noto Sans CJK via Homebrew on macOS

Usage:
  python3 gen_cover.py --title "主标题" --subtitle "副标题" --tags "标签1,标签2" --output cover.png

Font paths:
  The script searches common locations for Noto Sans CJK fonts on Linux and macOS.
  If fonts are not found, you may need to adjust the CJK_FONT_SEARCH_PATHS below
  to match your system's font installation.
"""
import argparse
import os
import sys

try:
    from PIL import Image, ImageDraw, ImageFont
except ImportError:
    print("Error: Pillow not installed. Run: pip install Pillow", file=sys.stderr)
    sys.exit(1)

W, H = 1080, 1440

GRADIENTS = {
    "purple": ((102, 126, 234), (240, 147, 251)),
    "blue":   ((30, 60, 180),   (100, 180, 255)),
    "green":  ((34, 139, 87),   (144, 238, 144)),
    "orange": ((255, 140, 0),   (255, 200, 100)),
    "dark":   ((30, 30, 60),    (80, 80, 120)),
}

# Search paths for CJK fonts — add your system's font paths if needed.
# The script tries each path in order and uses the first one found.
CJK_BOLD_SEARCH_PATHS = [
    # Linux (apt install fonts-noto-cjk)
    "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
    "/usr/share/fonts/noto-cjk/NotoSansCJK-Bold.ttc",
    # macOS (Homebrew: brew install font-noto-sans-cjk)
    "/opt/homebrew/share/fonts/NotoSansCJK-Bold.ttc",
    "/usr/local/share/fonts/NotoSansCJK-Bold.ttc",
    # macOS system fonts (partial CJK support)
    "/System/Library/Fonts/STHeiti Medium.ttc",
    "/System/Library/Fonts/Hiragino Sans GB.ttc",
]

CJK_REG_SEARCH_PATHS = [
    # Linux (apt install fonts-noto-cjk)
    "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
    "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
    # macOS (Homebrew: brew install font-noto-sans-cjk)
    "/opt/homebrew/share/fonts/NotoSansCJK-Regular.ttc",
    "/usr/local/share/fonts/NotoSansCJK-Regular.ttc",
    # macOS system fonts (partial CJK support)
    "/System/Library/Fonts/STHeiti Light.ttc",
    "/System/Library/Fonts/Hiragino Sans GB.ttc",
]


def find_font(search_paths, label):
    """Find the first available font from the search paths."""
    for path in search_paths:
        if os.path.exists(path):
            return path
    print(
        f"Error: {label} CJK font not found. Searched:\n"
        + "\n".join(f"  - {p}" for p in search_paths)
        + "\n\nOn Linux: apt install fonts-noto-cjk"
        + "\nOn macOS: brew install font-noto-sans-cjk",
        file=sys.stderr,
    )
    sys.exit(1)


CJK_BOLD = None
CJK_REG = None


def check_fonts():
    global CJK_BOLD, CJK_REG
    CJK_BOLD = find_font(CJK_BOLD_SEARCH_PATHS, "Bold")
    CJK_REG = find_font(CJK_REG_SEARCH_PATHS, "Regular")


def draw_gradient(draw, color_top, color_bot):
    for y in range(H):
        r = y / H
        c = tuple(int(color_top[i] + (color_bot[i] - color_top[i]) * r) for i in range(3))
        draw.line([(0, y), (W, y)], fill=c + (255,))


def add_decorative_circles(img):
    overlay = Image.new("RGBA", (W, H), (0, 0, 0, 0))
    od = ImageDraw.Draw(overlay)
    circles = [(200, 200, 150, 30), (900, 350, 200, 20),
               (150, 1100, 180, 25), (850, 1200, 130, 30)]
    for cx, cy, rad, alpha in circles:
        od.ellipse([cx - rad, cy - rad, cx + rad, cy + rad],
                   fill=(255, 255, 255, alpha))
    return Image.alpha_composite(img, overlay)


def draw_badge(img, text):
    draw = ImageDraw.Draw(img)
    font = ImageFont.truetype(CJK_REG, 34)
    bbox = draw.textbbox((0, 0), text, font=font)
    bw = bbox[2] - bbox[0] + 50
    bh = bbox[3] - bbox[1] + 24
    bx, by = W - bw - 50, 60

    badge_bg = Image.new("RGBA", (W, H), (0, 0, 0, 0))
    bd = ImageDraw.Draw(badge_bg)
    bd.rounded_rectangle([bx, by, bx + bw, by + bh], radius=bh // 2,
                         fill=(255, 255, 255, 60), outline=(255, 255, 255, 100), width=2)
    img = Image.alpha_composite(img, badge_bg)
    draw = ImageDraw.Draw(img)
    draw.text((bx + 25, by + 8), text, fill="white", font=font)
    return img


def draw_title(img, title, subtitle=None):
    draw = ImageDraw.Draw(img)
    font_huge = ImageFont.truetype(CJK_BOLD, 82)
    font_med  = ImageFont.truetype(CJK_BOLD, 48)

    # Split title into lines if too long (auto-wrap at ~8 chars)
    max_chars_per_line = 8
    lines = []
    while title:
        lines.append(title[:max_chars_per_line])
        title = title[max_chars_per_line:]

    # Calculate total height
    line_heights = []
    for line in lines:
        bb = draw.textbbox((0, 0), line, font=font_huge)
        line_heights.append(bb[3] - bb[1])

    total_h = sum(line_heights) + 40 * (len(lines) - 1)
    if subtitle:
        bbs = draw.textbbox((0, 0), subtitle, font=font_med)
        sub_h = bbs[3] - bbs[1]
        total_h += sub_h + 50

    start_y = (H - total_h) // 2 - 50
    y = start_y

    for i, line in enumerate(lines):
        bb = draw.textbbox((0, 0), line, font=font_huge)
        tw = bb[2] - bb[0]
        th = bb[3] - bb[1]
        x = (W - tw) // 2

        # Highlight last line
        if i == len(lines) - 1 and len(lines) > 1:
            hl = Image.new("RGBA", (W, H), (0, 0, 0, 0))
            hd = ImageDraw.Draw(hl)
            hd.rounded_rectangle([x - 25, y - 10, x + tw + 25, y + th + 15],
                                 radius=15, fill=(255, 255, 255, 50))
            img = Image.alpha_composite(img, hl)
            draw = ImageDraw.Draw(img)

        draw.text((x, y), line, fill="white", font=font_huge)
        y += th + 40

    if subtitle:
        y += 10
        bbs = draw.textbbox((0, 0), subtitle, font=font_med)
        tws = bbs[2] - bbs[0]
        draw.text(((W - tws) // 2, y), subtitle, fill=(255, 255, 255, 230), font=font_med)
        y += bbs[3] - bbs[1]

    return img, y


def draw_tags(img, tags, start_y):
    if not tags:
        return img
    draw = ImageDraw.Draw(img)
    font_tag = ImageFont.truetype(CJK_BOLD, 34)

    # Arrange in rows of 2-3
    rows = []
    row = []
    for tag in tags:
        row.append(tag)
        if len(row) >= 3 or (len(row) >= 2 and len(rows) == 0):
            rows.append(row)
            row = []
    if row:
        rows.append(row)

    y = start_y + 60
    for row_tags in rows:
        # Measure
        tag_widths = []
        for tag in row_tags:
            bbt = draw.textbbox((0, 0), tag, font=font_tag)
            tag_widths.append(bbt[2] - bbt[0] + 60)

        row_w = sum(tag_widths) + 20 * (len(tag_widths) - 1)
        rx = (W - row_w) // 2

        tag_layer = Image.new("RGBA", (W, H), (0, 0, 0, 0))
        td = ImageDraw.Draw(tag_layer)

        for tag, tw in zip(row_tags, tag_widths):
            bbt = draw.textbbox((0, 0), tag, font=font_tag)
            th = bbt[3] - bbt[1]
            td.rounded_rectangle([rx, y, rx + tw, y + th + 26],
                                 radius=(th + 26) // 2,
                                 fill=(255, 255, 255, 50),
                                 outline=(255, 255, 255, 80), width=1)
            # Need to composite then redraw
            img = Image.alpha_composite(img, tag_layer)
            draw = ImageDraw.Draw(img)
            draw.text((rx + 30, y + 10), tag, fill="white", font=font_tag)
            # Reset layer for next tag
            tag_layer = Image.new("RGBA", (W, H), (0, 0, 0, 0))
            td = ImageDraw.Draw(tag_layer)
            rx += tw + 20

        y += 75

    return img


def draw_logo(img, text="OpenClaw"):
    draw = ImageDraw.Draw(img)
    font = ImageFont.truetype(CJK_BOLD, 48)
    bb = draw.textbbox((0, 0), text, font=font)
    tw = bb[2] - bb[0]
    draw.text(((W - tw) // 2, H - 120), text, fill=(255, 255, 255, 200), font=font)
    return img


def main():
    parser = argparse.ArgumentParser(description="Generate 小红书 cover image")
    parser.add_argument("--title", required=True, help="Main title text")
    parser.add_argument("--subtitle", default=None, help="Subtitle text")
    parser.add_argument("--tags", default=None, help="Comma-separated feature tags")
    parser.add_argument("--badge", default="OpenClaw", help="Badge text (top-right)")
    parser.add_argument("--output", default="cover.png", help="Output file path")
    parser.add_argument("--gradient", default="purple", choices=GRADIENTS.keys(),
                        help="Color scheme")
    args = parser.parse_args()

    check_fonts()

    tags = [t.strip() for t in args.tags.split(",")] if args.tags else []

    # Create base image
    img = Image.new("RGBA", (W, H))
    draw = ImageDraw.Draw(img)

    # Gradient background
    color_top, color_bot = GRADIENTS[args.gradient]
    draw_gradient(draw, color_top, color_bot)

    # Decorative elements
    img = add_decorative_circles(img)

    # Badge
    img = draw_badge(img, args.badge)

    # Title + subtitle
    img, content_bottom = draw_title(img, args.title, args.subtitle)

    # Tags
    img = draw_tags(img, tags, content_bottom)

    # Logo
    img = draw_logo(img)

    # Save
    img.convert("RGB").save(args.output, "PNG", quality=95)
    size = os.path.getsize(args.output)
    print(f"Cover saved: {args.output} ({size:,} bytes)")


if __name__ == "__main__":
    main()


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