Compare commits

..

8 Commits

Author SHA1 Message Date
Jim Liu 宝玉 ced92e2231 Update marketplace.json version to 0.2.0 for improved clarity 2026-01-13 20:24:11 -06:00
Jim Liu 宝玉 102ef092ad Update slide deck skill documentation and prompt guidelines for improved clarity and functionality 2026-01-13 20:23:53 -06:00
Jim Liu 宝玉 cc553ddbb3 Refactor infographic generation skills and add new slide deck generator
- Updated article-illustrator prompts to emphasize hand-drawn style and layout principles.
- Enhanced xhs-images skill with multiple style options and auto-selection based on content signals.
- Created README in Chinese for better accessibility.
- Introduced cover-image skill for generating hand-drawn style cover images with various styles.
- Developed slide-deck skill to generate professional slide outlines with detailed structure and style instructions.
- Added prompts for cover-image and slide-deck skills to guide content generation.
2026-01-13 19:52:10 -06:00
Jim Liu 宝玉 2f4b5fcc85 add article-illustrator skill documentation with infographic guidelines 2026-01-13 19:15:43 -06:00
Jim Liu 宝玉 227b1dcdf6 new skill: post to x 2026-01-13 18:16:12 -06:00
Jim Liu 宝玉 b0ee7b3d33 refactor marketplace.json structure and remove obsolete plugin.json 2026-01-13 00:56:46 -06:00
Jim Liu 宝玉 e1aaae43b6 add readme 2026-01-13 00:49:59 -06:00
Jim Liu 宝玉 6eaa822542 add xhs-images prompt 2026-01-13 00:35:03 -06:00
20 changed files with 4009 additions and 21 deletions
+14 -7
View File
@@ -1,20 +1,27 @@
{
"name": "baoyu-skills",
"description": "Skills shared by Baoyu for improving daily work efficiency",
"owner": {
"name": "Jim Liu (宝玉)",
"email": "junminliu@gmail.com"
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "0.2.0"
},
"plugins": [
{
"name": "baoyu-skills",
"name": "content-skills",
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "0.1.0",
"source": "./",
"author": {
"name": "Jim Liu (宝玉)",
"email": "junminliu@gmail.com"
}
"strict": false,
"skills": [
"./skills/gemini-web",
"./skills/xhs-images",
"./skills/post-to-x",
"./skills/article-illustrator",
"./skills/cover-image",
"./skills/slide-deck"
]
}
]
}
-13
View File
@@ -1,13 +0,0 @@
{
"name": "baoyu-skills",
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "0.1.0",
"author": {
"name": "Jim Liu (宝玉)",
"email": "junminliu@gmail.com"
},
"homepage": "https://github.com/baoyu/baoyu-skills",
"repository": "https://github.com/baoyu/baoyu-skills",
"license": "MIT",
"keywords": ["skills", "translation", "productivity", "workflows"]
}
+2
View File
@@ -137,3 +137,5 @@ dist
# Vite logs files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
tests-data/
+119
View File
@@ -0,0 +1,119 @@
# baoyu-skills
English | [中文](./README.zh.md)
Skills shared by Baoyu for improving daily work efficiency with Claude Code.
## Prerequisites
- Node.js environment installed
- Ability to run `npx bun` commands
## Installation
### Register as Plugin Marketplace
Run the following command in Claude Code:
```bash
/plugin marketplace add jimliu/baoyu-skills
```
### Install Skills
**Option 1: Via Browse UI**
1. Select **Browse and install plugins**
2. Select **baoyu-skills**
3. Select **content-skills**
4. Select **Install now**
**Option 2: Direct Install**
```bash
/plugin install content-skills@baoyu-skills
```
## Available Skills
### gemini-web
Interacts with Gemini Web to generate text and images.
**Text Generation:**
```bash
/gemini-web "Hello, Gemini"
/gemini-web --prompt "Explain quantum computing"
```
**Image Generation:**
```bash
/gemini-web --prompt "A cute cat" --image cat.png
/gemini-web --promptfiles system.md content.md --image out.png
```
### xhs-images
Xiaohongshu (RedNote) infographic series generator. Breaks down content into 1-10 cartoon-style infographics.
```bash
# Specify article path
/xhs-images posts/ai-future/article.md
# Direct content input
/xhs-images 今日星座运势
```
### cover-image
Generate hand-drawn style cover images for articles with multiple style options.
```bash
# From markdown file (auto-select style)
/cover-image path/to/article.md
# Specify a style
/cover-image path/to/article.md --style tech
/cover-image path/to/article.md --style warm
# Without title text
/cover-image path/to/article.md --no-title
```
Available styles: `elegant` (default), `tech`, `warm`, `bold`, `minimal`, `playful`, `nature`, `retro`
### slide-deck
Generate professional slide deck outlines from content with comprehensive style instructions.
```bash
# From markdown file
/slide-deck path/to/article.md
# With style and audience
/slide-deck path/to/article.md --style corporate
/slide-deck path/to/article.md --audience executives
# With language
/slide-deck path/to/article.md --lang zh
```
Available styles: `editorial` (default), `corporate`, `technical`, `playful`, `minimal`, `storytelling`
## Disclaimer
### gemini-web
This skill uses the Gemini Web API (reverse-engineered).
**Warning:** This project uses unofficial API access via browser cookies. Use at your own risk.
- First run opens Chrome to authenticate with Google
- Cookies are cached for subsequent runs
- No guarantees on API stability or availability
## License
MIT
+119
View File
@@ -0,0 +1,119 @@
# baoyu-skills
[English](./README.md) | 中文
宝玉分享的 Claude Code 技能集,提升日常工作效率。
## 前置要求
- 已安装 Node.js 环境
- 能够运行 `npx bun` 命令
## 安装
### 注册插件市场
在 Claude Code 中运行:
```bash
/plugin marketplace add jimliu/baoyu-skills
```
### 安装技能
**方式一:通过浏览界面**
1. 选择 **Browse and install plugins**
2. 选择 **baoyu-skills**
3. 选择 **content-skills**
4. 选择 **Install now**
**方式二:直接安装**
```bash
/plugin install content-skills@baoyu-skills
```
## 可用技能
### gemini-web
与 Gemini Web 交互,生成文本和图片。
**文本生成:**
```bash
/gemini-web "你好,Gemini"
/gemini-web --prompt "解释量子计算"
```
**图片生成:**
```bash
/gemini-web --prompt "一只可爱的猫" --image cat.png
/gemini-web --promptfiles system.md content.md --image out.png
```
### xhs-images
小红书信息图系列生成器。将内容拆解为 1-10 张卡通风格信息图。
```bash
# 指定文章路径
/xhs-images posts/ai-future/article.md
# 直接输入内容
/xhs-images 今日星座运势
```
### cover-image
为文章生成手绘风格封面图,支持多种风格选项。
```bash
# 从 markdown 文件生成(自动选择风格)
/cover-image path/to/article.md
# 指定风格
/cover-image path/to/article.md --style tech
/cover-image path/to/article.md --style warm
# 不包含标题文字
/cover-image path/to/article.md --no-title
```
可用风格:`elegant`(默认)、`tech``warm``bold``minimal``playful``nature``retro`
### slide-deck
从内容生成专业的幻灯片大纲,包含完整的样式说明。
```bash
# 从 markdown 文件生成
/slide-deck path/to/article.md
# 指定风格和受众
/slide-deck path/to/article.md --style corporate
/slide-deck path/to/article.md --audience executives
# 指定语言
/slide-deck path/to/article.md --lang zh
```
可用风格:`editorial`(默认)、`corporate``technical``playful``minimal``storytelling`
## 免责声明
### gemini-web
此技能使用 Gemini Web API(逆向工程)。
**警告:** 本项目通过浏览器 cookies 使用非官方 API。使用风险自负。
- 首次运行会打开 Chrome 进行 Google 身份验证
- Cookies 会被缓存供后续使用
- 不保证 API 的稳定性或可用性
## 许可证
MIT
+313
View File
@@ -0,0 +1,313 @@
---
name: article-illustrator
description: Smart article illustration skill. Analyzes article content and generates illustrations at positions requiring visual aids with multiple style options. Use when user asks to "add illustrations to article", "generate images for article", or "illustrate article".
---
# Smart Article Illustration Skill
Analyze article structure and content, identify positions requiring visual aids, and generate illustrations with flexible style options.
## Usage
```bash
# Auto-select style based on content
/article-illustrator path/to/article.md
# Specify a style
/article-illustrator path/to/article.md --style tech
/article-illustrator path/to/article.md --style warm
/article-illustrator path/to/article.md --style minimal
# Combine with other options
/article-illustrator path/to/article.md --style playful
```
## Options
| Option | Description |
|--------|-------------|
| `--style <name>` | Specify illustration style (see Style Gallery below) |
## Style Gallery
### 1. `elegant` (Default)
Refined, sophisticated, professional
- **Colors**: Soft coral, muted teal, dusty rose, cream background
- **Elements**: Delicate line work, subtle icons, balanced composition
- **Best for**: Professional articles, thought leadership, business topics
### 2. `tech`
Modern, futuristic, digital
- **Colors**: Deep blue, electric cyan, purple, dark backgrounds
- **Elements**: Geometric shapes, circuit patterns, data visualizations, tech icons
- **Best for**: AI, programming, technology, digital transformation articles
### 3. `warm`
Friendly, approachable, human-centered
- **Colors**: Warm orange, golden yellow, terracotta, cream
- **Elements**: Rounded shapes, friendly characters, sun/light motifs, hearts
- **Best for**: Personal growth, lifestyle, education, human interest stories
### 4. `bold`
High contrast, impactful, energetic
- **Colors**: Vibrant red/orange, deep black, bright yellow accents
- **Elements**: Strong shapes, dramatic contrast, dynamic compositions
- **Best for**: Opinion pieces, important warnings, call-to-action content
### 5. `minimal`
Ultra-clean, zen-like, focused
- **Colors**: Black, white, single accent color
- **Elements**: Maximum whitespace, single focal element, simple lines
- **Best for**: Philosophy, minimalism, focused explanations
### 6. `playful`
Fun, creative, whimsical
- **Colors**: Pastel rainbow, bright pops, light backgrounds
- **Elements**: Doodles, quirky characters, speech bubbles, emoji-style icons
- **Best for**: Tutorials, beginner guides, casual content, fun topics
### 7. `nature`
Organic, calm, earthy
- **Colors**: Forest green, earth brown, sky blue, sand beige
- **Elements**: Plant motifs, natural textures, flowing lines, organic shapes
- **Best for**: Sustainability, wellness, outdoor topics, slow living
### 8. `sketch`
Raw, authentic, notebook-style
- **Colors**: Pencil gray, paper white, occasional color highlights
- **Elements**: Rough lines, sketch marks, handwritten notes, arrows
- **Best for**: Ideas in progress, brainstorming, thought processes
## Auto Style Selection
When no `--style` is specified, analyze content to select the best style:
| Content Signals | Selected Style |
|----------------|----------------|
| AI, coding, tech, digital, algorithm, data | `tech` |
| Personal story, emotion, growth, life, feeling | `warm` |
| Warning, urgent, must-read, critical, important | `bold` |
| Simple, zen, focus, essential, core | `minimal` |
| Fun, easy, beginner, tutorial, guide, how-to | `playful` |
| Nature, eco, wellness, health, organic, green | `nature` |
| Idea, thought, concept, draft, brainstorm | `sketch` |
| Business, professional, strategy, analysis | `elegant` |
## File Management
Save illustrations to `imgs/` subdirectory in the same folder as the article:
```
path/to/
├── article.md
└── imgs/
├── outline.md
├── prompts/
│ ├── illustration-concept-a.md
│ ├── illustration-concept-b.md
│ └── ...
├── illustration-concept-a.png
├── illustration-concept-b.png
└── ...
```
## Workflow
### Step 1: Analyze Content & Select Style
1. Read article content
2. If `--style` specified, use that style
3. Otherwise, scan for style signals and auto-select
4. Extract key information:
- Main topic and themes
- Core messages per section
- Abstract concepts needing visualization
### Step 2: Identify Illustration Positions
**Three Purposes of Illustrations**:
1. **Information Supplement**: Help understand abstract concepts
2. **Concept Visualization**: Transform abstract ideas into concrete visuals
3. **Imagination Guidance**: Create atmosphere, enhance reading experience
**Content Suitable for Illustrations**:
- Abstract concepts needing visualization
- Processes/steps needing diagrams
- Comparisons needing visual representation
- Core arguments needing reinforcement
- Scenarios needing imagination guidance
**Illustration Count**:
- Consider at least 1 image per major section
- Prioritize core arguments and abstract concepts
- **Principle: More is better than fewer**
### Step 3: Generate Illustration Plan
```markdown
# Illustration Plan
**Article**: [article path]
**Style**: [selected style]
**Illustration Count**: N images
---
## Illustration 1
**Insert Position**: [section name] / [paragraph description]
**Purpose**: [why illustration needed here]
**Visual Content**: [what the image should show]
**Filename**: illustration-[slug].png
---
## Illustration 2
...
```
### Step 4: Create Prompt Files
Save prompts to `prompts/` directory with style-specific details.
**Prompt Format**:
```markdown
Illustration theme: [concept in 2-3 words]
Style: [style name]
Visual composition:
- Main visual: [description matching style]
- Layout: [element positioning]
- Decorative elements: [style-appropriate decorations]
Color scheme:
- Primary: [style primary color]
- Background: [style background color]
- Accent: [style accent color]
Text content (if any):
- [Any labels or captions in content language]
Style notes: [specific style characteristics]
```
### Step 5: Generate Images
For each illustration, call:
```bash
/gemini-web --promptfiles [SKILL_ROOT]/skills/article-illustrator/prompts/system.md [ARTICLE_DIR]/imgs/prompts/illustration-[slug].md --image [ARTICLE_DIR]/imgs/illustration-[slug].png
```
Generation flow:
1. Generate images sequentially
2. After each image, output progress: "Generated X/N"
3. On failure, auto-retry once
4. If retry fails, log reason, continue to next
### Step 6: Update Article
Insert generated images at corresponding positions:
```markdown
![illustration description](imgs/illustration-[slug].png)
```
**Insertion Rules**:
- Insert image after corresponding paragraph
- Leave one blank line before and after image
- Alt text uses concise description in article's language
### Step 7: Output Summary
```
Article Illustration Complete!
Article: [article path]
Style: [style name]
Generated: X/N images successful
Illustration Positions:
- illustration-xxx.png → After section "Section Name"
- illustration-yyy.png → After section "Another Section"
...
[If any failures]
Failed:
- illustration-zzz.png: [failure reason]
```
## Style Reference Details
### elegant
```
Colors: Soft coral (#E8A598), muted teal (#5B8A8A), dusty rose (#D4A5A5)
Background: Warm cream (#F5F0E6), soft beige
Accents: Gold (#C9A962), copper
Elements: Delicate lines, refined icons, subtle gradients, balanced whitespace
```
### tech
```
Colors: Deep blue (#1A365D), electric cyan (#00D4FF), purple (#6B46C1)
Background: Dark gray (#1A202C), near-black (#0D1117)
Accents: Neon green (#00FF88), bright white
Elements: Circuit patterns, data nodes, geometric grids, glowing effects
```
### warm
```
Colors: Warm orange (#ED8936), golden yellow (#F6AD55), terracotta (#C05621)
Background: Cream (#FFFAF0), soft peach (#FED7AA)
Accents: Deep brown (#744210), soft red
Elements: Rounded shapes, smiling faces, sun rays, hearts, cozy lighting
```
### bold
```
Colors: Vibrant red (#E53E3E), bright orange (#DD6B20), electric yellow (#F6E05E)
Background: Deep black (#000000), dark charcoal
Accents: White, neon highlights
Elements: Strong shapes, exclamation marks, arrows, dramatic contrast
```
### minimal
```
Colors: Pure black (#000000), white (#FFFFFF)
Background: White or off-white (#FAFAFA)
Accents: Single color (content-derived)
Elements: Single focal point, maximum negative space, thin precise lines
```
### playful
```
Colors: Pastel pink (#FED7E2), mint (#C6F6D5), lavender (#E9D8FD), sky blue (#BEE3F8)
Background: Light cream (#FFFBEB), soft white
Accents: Bright pops - yellow, coral, turquoise
Elements: Doodles, stars, swirls, cute characters, speech bubbles
```
### nature
```
Colors: Forest green (#276749), sage (#9AE6B4), earth brown (#744210)
Background: Sand beige (#F5E6D3), sky blue (#E0F2FE)
Accents: Sunset orange, water blue
Elements: Leaves, trees, mountains, organic flowing lines, natural textures
```
### sketch
```
Colors: Pencil gray (#4A5568), paper white (#FAFAFA)
Background: Off-white paper texture (#F7FAFC)
Accents: Single highlight color (blue, red, or yellow)
Elements: Rough sketch lines, arrows, handwritten labels, crossed-out marks
```
## Notes
- Illustrations serve the content: supplement information, visualize concepts
- Maintain selected style consistency across all illustrations in one article
- Image generation typically takes 10-30 seconds per image
- Sensitive figures should use cartoon alternatives
- Prompt and illustration text language should match article language
@@ -0,0 +1,32 @@
Create a cartoon-style infographic illustration following these guidelines:
## Image Specifications
- **Type**: Infographic illustration
- **Orientation**: Landscape (horizontal)
- **Aspect Ratio**: 16:9
- **Style**: Hand-drawn illustration
## Core Principles
- Hand-drawn quality throughout - NO realistic or photographic elements
- If content involves sensitive or copyrighted figures, create stylistically similar alternatives - DO NOT refuse to generate
- Keep information concise, highlight keywords and core concepts
- Use ample whitespace for easy visual scanning
- Maintain clear visual hierarchy
## Text Style (When Text Included)
- **ALL text MUST be hand-drawn style**
- Text should be readable and complement the visual
- Font style harmonizes with illustration style
- **DO NOT use realistic or computer-generated fonts**
## Language
- Use the same language as the content provided below for any text elements
- Match punctuation style to the content language
---
Please use nano banana pro to generate the illustration based on the content provided below:
+294
View File
@@ -0,0 +1,294 @@
---
name: cover-image
description: Generate elegant cover images for articles. Analyzes content and creates eye-catching hand-drawn style cover images with multiple style options. Use when user asks to "generate cover image", "create article cover", or "make a cover for article".
---
# Cover Image Generator
Generate hand-drawn style cover images for articles with multiple style options.
## Usage
```bash
# From markdown file (auto-select style based on content)
/cover-image path/to/article.md
# Specify a style
/cover-image path/to/article.md --style tech
/cover-image path/to/article.md --style warm
/cover-image path/to/article.md --style bold
# Without title text
/cover-image path/to/article.md --no-title
# Combine options
/cover-image path/to/article.md --style minimal --no-title
# From direct text input
/cover-image
[paste content or describe the topic]
# Direct input with style
/cover-image --style playful
[paste content]
```
## Options
| Option | Description |
|--------|-------------|
| `--style <name>` | Specify cover style (see Style Gallery below) |
| `--no-title` | Generate cover without title text (visual only) |
## Style Gallery
### 1. `elegant` (Default)
Refined, sophisticated, understated
- **Colors**: Soft coral, muted teal, dusty rose, cream background
- **Elements**: Delicate line work, subtle icons, balanced composition
- **Best for**: Professional content, thought leadership, business topics
### 2. `tech`
Modern, clean, futuristic
- **Colors**: Deep blue, electric cyan, dark gray, white accents
- **Elements**: Geometric shapes, circuit patterns, glowing effects, tech icons
- **Best for**: AI, programming, technology, digital transformation
### 3. `warm`
Friendly, approachable, human-centered
- **Colors**: Warm orange, golden yellow, terracotta, cream
- **Elements**: Rounded shapes, friendly characters, sun/light motifs
- **Best for**: Personal growth, lifestyle, education, human stories
### 4. `bold`
High contrast, attention-grabbing, energetic
- **Colors**: Vibrant red/orange, deep black, bright yellow accents
- **Elements**: Strong typography, dramatic contrast, dynamic shapes
- **Best for**: Opinion pieces, controversial takes, urgent topics
### 5. `minimal`
Ultra-clean, zen-like, focused
- **Colors**: Black, white, single accent color
- **Elements**: Maximum whitespace, single focal element, simple lines
- **Best for**: Philosophy, minimalism, focused concepts
### 6. `playful`
Fun, creative, whimsical
- **Colors**: Pastel rainbow, bright pops of color, light backgrounds
- **Elements**: Doodles, quirky characters, speech bubbles, emoji-style icons
- **Best for**: Casual content, tutorials, beginner guides, fun topics
### 7. `nature`
Organic, calm, earthy
- **Colors**: Forest green, earth brown, sky blue, sand beige
- **Elements**: Plant motifs, natural textures, flowing lines, organic shapes
- **Best for**: Sustainability, wellness, outdoor topics, slow living
### 8. `retro`
Vintage, nostalgic, classic
- **Colors**: Muted pastels, sepia tones, faded colors
- **Elements**: Vintage typography, halftone dots, classic illustrations
- **Best for**: History, retrospectives, classic topics, throwback content
## Auto Style Selection
When no `--style` is specified, the system analyzes content to select the best style:
| Content Signals | Selected Style |
|----------------|----------------|
| AI, coding, tech, digital, algorithm | `tech` |
| Personal story, emotion, growth, life | `warm` |
| Controversial, urgent, must-read, warning | `bold` |
| Simple, zen, focus, essential | `minimal` |
| Fun, easy, beginner, casual, tutorial | `playful` |
| Nature, eco, wellness, health, organic | `nature` |
| History, classic, vintage, old, traditional | `retro` |
| Business, professional, strategy, analysis | `elegant` |
## File Management
### With Article Path
Save to `imgs/` subdirectory in the same folder as the article:
```
path/to/
├── article.md
└── imgs/
├── prompts/
│ └── cover.md
└── cover.png
```
### Without Article Path
Save to current working directory:
```
./
├── cover-prompt.md
└── cover.png
```
## Workflow
### Step 1: Analyze Content
Extract key information:
- **Main topic**: What is the article about?
- **Core message**: What's the key takeaway?
- **Tone**: Serious, playful, inspiring, educational?
- **Keywords**: Identify style-signaling words
### Step 2: Select Style
If `--style` specified, use that style. Otherwise:
1. Scan content for style signals (see Auto Style Selection table)
2. Match signals to most appropriate style
3. Default to `elegant` if no clear signals
### Step 3: Generate Cover Concept
Create a cover image concept based on selected style:
**Title** (if included, max 8 characters):
- Distill the core message into a punchy headline
- Use hooks: numbers, questions, contrasts, pain points
- Skip if `--no-title` flag is used
**Visual Elements**:
- Style-appropriate imagery and icons
- 1-2 symbolic elements representing the topic
- Metaphors or analogies that fit the style
### Step 4: Create Prompt File
**Prompt Format**:
```markdown
Cover theme: [topic in 2-3 words]
Style: [selected style name]
[If title included:]
Title text: [8 characters or less, in content language]
Subtitle: [optional, in content language]
Visual composition:
- Main visual: [description matching style]
- Layout: [positioning based on title inclusion]
- Decorative elements: [style-appropriate elements]
Color scheme:
- Primary: [style primary color]
- Background: [style background color]
- Accent: [style accent color]
Style notes: [specific style characteristics to emphasize]
[If no title:]
Note: No title text, pure visual illustration only.
```
### Step 5: Generate Image
Call gemini-web to generate the cover:
```bash
/gemini-web --promptfiles [SKILL_ROOT]/skills/cover-image/prompts/system.md [PROMPT_PATH] --image [OUTPUT_PATH]
```
### Step 6: Output Summary
```
Cover Image Generated!
Topic: [topic]
Style: [style name]
Title: [cover title] (or "No title - visual only")
Location: [output path]
Preview the image to verify it matches your expectations.
```
## Style Reference Details
### elegant
```
Colors: Soft coral (#E8A598), muted teal (#5B8A8A), dusty rose (#D4A5A5)
Background: Warm cream (#F5F0E6), soft beige
Accents: Gold (#C9A962), copper
Elements: Delicate lines, refined icons, subtle gradients
Typography: Elegant serif-style hand lettering
```
### tech
```
Colors: Deep blue (#1A365D), electric cyan (#00D4FF), purple (#6B46C1)
Background: Dark gray (#1A202C), near-black (#0D1117)
Accents: Neon green (#00FF88), bright white
Elements: Circuit patterns, data nodes, geometric grids, code snippets
Typography: Monospace-style hand lettering, glowing effects
```
### warm
```
Colors: Warm orange (#ED8936), golden yellow (#F6AD55), terracotta (#C05621)
Background: Cream (#FFFAF0), soft peach (#FED7AA)
Accents: Deep brown (#744210), soft red
Elements: Rounded shapes, smiling faces, sun rays, hearts, warm lighting
Typography: Friendly rounded hand lettering
```
### bold
```
Colors: Vibrant red (#E53E3E), bright orange (#DD6B20), electric yellow (#F6E05E)
Background: Deep black (#000000), dark charcoal
Accents: White, neon highlights
Elements: Exclamation marks, lightning bolts, arrows, strong shapes
Typography: Bold, impactful, large hand lettering with shadows
```
### minimal
```
Colors: Pure black (#000000), white (#FFFFFF)
Background: White or off-white (#FAFAFA)
Accents: Single color (user's choice or content-derived)
Elements: Single focal point, maximum negative space, thin lines
Typography: Clean, simple hand lettering, lots of breathing room
```
### playful
```
Colors: Pastel pink (#FED7E2), mint (#C6F6D5), lavender (#E9D8FD), sky blue (#BEE3F8)
Background: Light cream (#FFFBEB), soft white
Accents: Bright pops - yellow, coral, turquoise
Elements: Doodles, stars, swirls, cute characters, emoji-style icons
Typography: Bouncy, irregular hand lettering, playful angles
```
### nature
```
Colors: Forest green (#276749), sage (#9AE6B4), earth brown (#744210)
Background: Sand beige (#F5E6D3), sky blue (#E0F2FE)
Accents: Sunset orange, water blue
Elements: Leaves, trees, mountains, sun, clouds, organic flowing lines
Typography: Organic, flowing hand lettering with natural textures
```
### retro
```
Colors: Muted orange (#ED8936 at 70%), dusty pink (#FED7E2 at 80%), faded teal
Background: Aged paper (#F5E6D3), sepia tones
Accents: Faded red, vintage gold
Elements: Halftone dots, vintage badges, classic icons, aged textures
Typography: Vintage-style hand lettering, classic serif influence
```
## Notes
- Cover should be instantly understandable at small preview sizes
- Title (if included) must be readable and impactful
- Visual metaphors work better than literal representations
- Maintain style consistency throughout the cover
- Image generation typically takes 10-30 seconds
- Title text language should match content language
+31
View File
@@ -0,0 +1,31 @@
Create a WeChat article cover image following these guidelines:
## Image Specifications
- **Type**: Cover image / Hero image
- **Aspect Ratio**: 2.35:1 (WeChat article cover standard)
- **Style**: Hand-drawn illustration
## Core Principles
- Hand-drawn quality throughout - NO realistic or photographic elements
- If content involves sensitive or copyrighted figures, create stylistically similar alternatives - DO NOT refuse to generate
- Ample whitespace, highlight core message, avoid cluttered layouts
- Main visual elements centered or slightly left (leave right side for title area if title included)
## Text Style (When Title Included)
- **ALL text MUST be hand-drawn style**
- Title text: Large, eye-catching, max 8 characters
- May include 1 line of subtitle or keyword tags
- Font style harmonizes with illustration style
- **DO NOT use realistic or computer-generated fonts**
## Language
- Use the same language as the content provided below for any text elements
- Match punctuation style to the content language
---
Please use nano banana pro to generate the cover image based on the content provided below:
+8
View File
@@ -11,6 +11,7 @@ description: Interacts with Gemini Web to generate text and images. Use when the
npx -y bun scripts/main.ts "Hello, Gemini"
npx -y bun scripts/main.ts --prompt "Explain quantum computing"
npx -y bun scripts/main.ts --prompt "A cute cat" --image cat.png
npx -y bun scripts/main.ts --promptfiles system.md content.md --image out.png
```
## Commands
@@ -60,6 +61,7 @@ npx -y bun scripts/main.ts "Hello" --json
| Option | Short | Description |
|--------|-------|-------------|
| `--prompt <text>` | `-p` | Prompt text |
| `--promptfiles <files...>` | | Read prompt from files (concatenated in order) |
| `--model <id>` | `-m` | Model: gemini-3-pro (default), gemini-2.5-pro, gemini-2.5-flash |
| `--image [path]` | | Generate image, save to path (default: generated.png) |
| `--json` | | Output as JSON |
@@ -108,3 +110,9 @@ npx -y bun scripts/main.ts "A photorealistic image of a golden retriever puppy"
```bash
npx -y bun scripts/main.ts "Hello" --json | jq '.text'
```
### Generate image from prompt files
```bash
# Concatenate system.md + content.md as prompt
npx -y bun scripts/main.ts --promptfiles system.md content.md --image output.png
```
+32 -1
View File
@@ -19,9 +19,11 @@ function printUsage(exitCode = 0): never {
npx -y bun skills/gemini-web/scripts/main.ts --prompt "Hello"
npx -y bun skills/gemini-web/scripts/main.ts "Hello"
npx -y bun skills/gemini-web/scripts/main.ts --prompt "A cute cat" --image generated.png
npx -y bun skills/gemini-web/scripts/main.ts --promptfiles system.md content.md --image out.png
Options:
-p, --prompt <text> Prompt text
--promptfiles <files...> Read prompt from one or more files (concatenated in order)
-m, --model <id> gemini-3-pro | gemini-2.5-pro | gemini-2.5-flash (default: gemini-3-pro)
--json Output JSON
--image [path] Generate an image and save it (default: ./generated.png)
@@ -51,8 +53,22 @@ async function readPromptFromStdin(): Promise<string | null> {
return text ? text : null;
}
function readPromptFiles(filePaths: string[]): string {
const contents: string[] = [];
for (const filePath of filePaths) {
const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
if (!fs.existsSync(resolved)) {
throw new Error(`Prompt file not found: ${resolved}`);
}
const content = fs.readFileSync(resolved, 'utf8').trim();
contents.push(content);
}
return contents.join('\n\n');
}
function parseArgs(argv: string[]): {
prompt?: string;
promptFiles?: string[];
model?: string;
json?: boolean;
imagePath?: string;
@@ -101,6 +117,19 @@ function parseArgs(argv: string[]): {
out.prompt = arg.slice('--prompt='.length);
continue;
}
if (arg === '--promptfiles') {
out.promptFiles = [];
while (i + 1 < argv.length) {
const next = argv[i + 1];
if (next && !next.startsWith('-')) {
out.promptFiles.push(next);
i += 1;
} else {
break;
}
}
continue;
}
if (arg === '--model' || arg === '-m') {
out.model = argv[i + 1] ?? '';
i += 1;
@@ -148,6 +177,7 @@ function parseArgs(argv: string[]): {
if (out.imagePath === '') delete out.imagePath;
if (out.cookiePath === '') delete out.cookiePath;
if (out.profileDir === '') delete out.profileDir;
if (out.promptFiles?.length === 0) delete out.promptFiles;
return out;
}
@@ -227,7 +257,8 @@ async function main(): Promise<void> {
}
const promptFromStdin = await readPromptFromStdin();
const prompt = args.prompt || promptFromStdin;
const promptFromFiles = args.promptFiles ? readPromptFiles(args.promptFiles) : null;
const prompt = promptFromFiles || args.prompt || promptFromStdin;
if (!prompt) printUsage(1);
let cookieMap = await ensureGeminiCookieMap({ cookiePath, profileDir });
+339
View File
@@ -0,0 +1,339 @@
---
name: post-to-x
description: Post content and articles to X (Twitter). Supports regular posts with images and X Articles (long-form Markdown). Uses real Chrome with CDP to bypass anti-automation.
---
# Post to X (Twitter)
Post content, images, and long-form articles to X using real Chrome browser (bypasses anti-bot detection).
## Features
- **Regular Posts**: Text + up to 4 images
- **X Articles**: Publish Markdown files with rich formatting and images (requires X Premium)
## Usage
```bash
# Post text only
/post-to-x "Your post content here"
# Post with image
/post-to-x "Your post content" --image /path/to/image.png
# Post with multiple images (up to 4)
/post-to-x "Your post content" --image img1.png --image img2.png
# Actually submit the post
/post-to-x "Your post content" --submit
```
## Prerequisites
- Google Chrome or Chromium installed
- `bun` installed (for running scripts)
- First run: log in to X in the opened browser window
## Quick Start (Recommended)
Use the `x-browser.ts` script directly:
```bash
# Preview mode (doesn't post)
npx -y bun ./scripts/x-browser.ts "Hello from Claude!" --image ./screenshot.png
# Actually post
npx -y bun ./scripts/x-browser.ts "Hello!" --image ./photo.png --submit
```
The script:
1. Launches real Chrome with anti-detection disabled
2. Uses persistent profile (only need to log in once)
3. Types text and pastes images via CDP
4. Waits 30s for preview (or posts immediately with `--submit`)
## Manual Workflow
If you prefer step-by-step control:
### Step 1: Copy Image to Clipboard
```bash
npx -y bun ./scripts/copy-to-clipboard.ts image /path/to/image.png
```
### Step 2: Use Playwright MCP (if Chrome session available)
```bash
# Navigate
mcp__playwright__browser_navigate url="https://x.com/compose/post"
# Get element refs
mcp__playwright__browser_snapshot
# Type text
mcp__playwright__browser_click element="editor" ref="<ref>"
mcp__playwright__browser_type element="editor" ref="<ref>" text="Your content"
# Paste image (after copying to clipboard)
mcp__playwright__browser_press_key key="Meta+v" # macOS
# or
mcp__playwright__browser_press_key key="Control+v" # Windows/Linux
# Screenshot to verify
mcp__playwright__browser_take_screenshot filename="preview.png"
```
## Parameters
| Parameter | Description |
|-----------|-------------|
| `<text>` | Post content (positional argument) |
| `--image <path>` | Image file path (can be repeated, max 4) |
| `--submit` | Actually post (default: preview only) |
| `--profile <dir>` | Custom Chrome profile directory |
## Image Support
- Formats: PNG, JPEG, GIF, WebP
- Max 4 images per post
- Images copied to system clipboard, then pasted via keyboard shortcut
## Example Session
```
User: /post-to-x "Hello from Claude!" --image ./screenshot.png
Claude:
1. Runs: npx -y bun ./scripts/x-browser.ts "Hello from Claude!" --image ./screenshot.png
2. Chrome opens with X compose page
3. Text is typed into editor
4. Image is copied to clipboard and pasted
5. Browser stays open 30s for preview
6. Reports: "Post composed. Use --submit to post."
```
## Troubleshooting
- **Chrome not found**: Set `X_BROWSER_CHROME_PATH` environment variable
- **Not logged in**: First run opens Chrome - log in manually, cookies are saved
- **Image paste fails**: Verify clipboard script: `npx -y bun ./scripts/copy-to-clipboard.ts image <path>`
- **Rate limited**: Wait a few minutes before retrying
## How It Works
The `x-browser.ts` script uses Chrome DevTools Protocol (CDP) to:
1. Launch real Chrome (not Playwright) with `--disable-blink-features=AutomationControlled`
2. Use persistent profile directory for saved login sessions
3. Interact with X via CDP commands (Runtime.evaluate, Input.dispatchKeyEvent)
4. Paste images from system clipboard
This approach bypasses X's anti-automation detection that blocks Playwright/Puppeteer.
## Notes
- First run requires manual login (session is saved)
- Always preview before using `--submit`
- Browser closes automatically after operation
- Supports macOS, Linux, and Windows
---
# X Articles (Long-form Publishing)
Publish Markdown articles to X Articles editor with rich text formatting and images.
## X Article Usage
```bash
# Publish markdown article (preview mode)
/post-to-x article /path/to/article.md
# With custom cover image
/post-to-x article article.md --cover ./hero.png
# With custom title
/post-to-x article article.md --title "My Custom Title"
# Actually publish (not just draft)
/post-to-x article article.md --submit
```
## Prerequisites for Articles
- X Premium subscription (required for Articles)
- Google Chrome installed
- `bun` installed
## Article Script
Use `x-article.ts` directly:
```bash
npx -y bun ./scripts/x-article.ts article.md
npx -y bun ./scripts/x-article.ts article.md --cover ./cover.jpg
npx -y bun ./scripts/x-article.ts article.md --submit
```
## Markdown Format
```markdown
---
title: My Article Title
cover_image: /path/to/cover.jpg
---
# Title (becomes article title)
Regular paragraph text with **bold** and *italic*.
## Section Header
More content here.
![Image alt text](./image.png)
- List item 1
- List item 2
1. Numbered item
2. Another item
> Blockquote text
[Link text](https://example.com)
\`\`\`
Code blocks become blockquotes (X doesn't support code)
\`\`\`
```
## Frontmatter Fields
| Field | Description |
|-------|-------------|
| `title` | Article title (or uses first H1) |
| `cover_image` | Cover image path or URL |
| `cover` | Alias for cover_image |
| `image` | Alias for cover_image |
## Image Handling
1. **Cover Image**: First image or `cover_image` from frontmatter
2. **Remote Images**: Automatically downloaded to temp directory
3. **Placeholders**: Images in content use `[[IMAGE_PLACEHOLDER_N]]` format
4. **Insertion**: Placeholders are found, selected, and replaced with actual images
## Markdown to HTML Script
Convert markdown and inspect structure:
```bash
# Get JSON with all metadata
npx -y bun ./scripts/md-to-html.ts article.md
# Output HTML only
npx -y bun ./scripts/md-to-html.ts article.md --html-only
# Save HTML to file
npx -y bun ./scripts/md-to-html.ts article.md --save-html /tmp/article.html
```
JSON output:
```json
{
"title": "Article Title",
"coverImage": "/path/to/cover.jpg",
"contentImages": [
{
"placeholder": "[[IMAGE_PLACEHOLDER_1]]",
"localPath": "/tmp/x-article-images/img.png",
"blockIndex": 5
}
],
"html": "<p>Content...</p>",
"totalBlocks": 20
}
```
## Supported Formatting
| Markdown | HTML Output |
|----------|-------------|
| `# H1` | Title only (not in body) |
| `## H2` - `###### H6` | `<h2>` |
| `**bold**` | `<strong>` |
| `*italic*` | `<em>` |
| `[text](url)` | `<a href>` |
| `> quote` | `<blockquote>` |
| `` `code` `` | `<code>` |
| ```` ``` ```` | `<blockquote>` (X limitation) |
| `- item` | `<ul><li>` |
| `1. item` | `<ol><li>` |
| `![](img)` | Image placeholder |
## Article Workflow
1. **Parse Markdown**: Extract title, cover, content images, generate HTML
2. **Launch Chrome**: Real browser with CDP, persistent login
3. **Navigate**: Open `x.com/compose/articles`
4. **Create Article**: Click create button if on list page
5. **Upload Cover**: Use file input for cover image
6. **Fill Title**: Type title into title field
7. **Paste Content**: Copy HTML to clipboard, paste into editor
8. **Insert Images**: For each placeholder (reverse order):
- Find placeholder text in editor
- Select the placeholder
- Copy image to clipboard
- Paste to replace selection
9. **Review**: Browser stays open for 60s preview
10. **Publish**: Only with `--submit` flag
## Article Example Session
```
User: /post-to-x article ./blog/my-post.md --cover ./thumbnail.png
Claude:
1. Parses markdown: title="My Post", 3 content images
2. Launches Chrome with CDP
3. Navigates to x.com/compose/articles
4. Clicks create button
5. Uploads thumbnail.png as cover
6. Fills title "My Post"
7. Pastes HTML content
8. Inserts 3 images at placeholder positions
9. Reports: "Article composed. Review and use --submit to publish."
```
## Article Troubleshooting
- **No create button**: Ensure X Premium subscription is active
- **Cover upload fails**: Check file path and format (PNG, JPEG)
- **Images not inserting**: Verify placeholders exist in pasted content
- **Content not pasting**: Check HTML clipboard: `npx -y bun ./scripts/copy-to-clipboard.ts html --file /tmp/test.html`
## How Article Publishing Works
1. `md-to-html.ts` converts Markdown to HTML:
- Extracts frontmatter (title, cover)
- Converts markdown to HTML
- Replaces images with unique placeholders
- Downloads remote images locally
- Returns structured JSON
2. `x-article.ts` publishes via CDP:
- Launches real Chrome (bypasses detection)
- Uses persistent profile (saved login)
- Navigates and fills editor via DOM manipulation
- Pastes HTML from system clipboard
- Finds/selects/replaces each image placeholder
## Scripts Reference
| Script | Purpose |
|--------|---------|
| `x-browser.ts` | Regular posts (text + images) |
| `x-article.ts` | Article publishing (Markdown) |
| `md-to-html.ts` | Markdown → HTML conversion |
| `copy-to-clipboard.ts` | Copy image/HTML to clipboard |
@@ -0,0 +1,380 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
const SUPPORTED_IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
function printUsage(exitCode = 0): never {
console.log(`Copy image or HTML to system clipboard
Supports:
- Image files (jpg, png, gif, webp) - copies as image data
- HTML content - copies as rich text for paste
Usage:
# Copy image to clipboard
npx -y bun copy-to-clipboard.ts image /path/to/image.jpg
# Copy HTML to clipboard
npx -y bun copy-to-clipboard.ts html "<p>Hello</p>"
# Copy HTML from file
npx -y bun copy-to-clipboard.ts html --file /path/to/content.html
`);
process.exit(exitCode);
}
function resolvePath(filePath: string): string {
return path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
}
function inferImageMimeType(imagePath: string): string {
const ext = path.extname(imagePath).toLowerCase();
switch (ext) {
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.png':
return 'image/png';
case '.gif':
return 'image/gif';
case '.webp':
return 'image/webp';
default:
return 'application/octet-stream';
}
}
type RunResult = { stdout: string; stderr: string; exitCode: number };
async function runCommand(
command: string,
args: string[],
options?: { input?: string | Buffer; allowNonZeroExit?: boolean },
): Promise<RunResult> {
return await new Promise<RunResult>((resolve, reject) => {
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk)));
child.stderr.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk)));
child.on('error', reject);
child.on('close', (code) => {
resolve({
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
stderr: Buffer.concat(stderrChunks).toString('utf8'),
exitCode: code ?? 0,
});
});
if (options?.input != null) child.stdin.write(options.input);
child.stdin.end();
}).then((result) => {
if (!options?.allowNonZeroExit && result.exitCode !== 0) {
const details = result.stderr.trim() || result.stdout.trim();
throw new Error(`Command failed (${command}): exit ${result.exitCode}${details ? `\n${details}` : ''}`);
}
return result;
});
}
async function commandExists(command: string): Promise<boolean> {
if (process.platform === 'win32') {
const result = await runCommand('where', [command], { allowNonZeroExit: true });
return result.exitCode === 0 && result.stdout.trim().length > 0;
}
const result = await runCommand('which', [command], { allowNonZeroExit: true });
return result.exitCode === 0 && result.stdout.trim().length > 0;
}
async function runCommandWithFileStdin(command: string, args: string[], filePath: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
const stderrChunks: Buffer[] = [];
const stdoutChunks: Buffer[] = [];
child.stdout.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk)));
child.stderr.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk)));
child.on('error', reject);
child.on('close', (code) => {
const exitCode = code ?? 0;
if (exitCode !== 0) {
const details = Buffer.concat(stderrChunks).toString('utf8').trim() || Buffer.concat(stdoutChunks).toString('utf8').trim();
reject(
new Error(`Command failed (${command}): exit ${exitCode}${details ? `\n${details}` : ''}`),
);
return;
}
resolve();
});
fs.createReadStream(filePath).on('error', reject).pipe(child.stdin);
});
}
async function withTempDir<T>(prefix: string, fn: (tempDir: string) => Promise<T>): Promise<T> {
const tempDir = await mkdtemp(path.join(os.tmpdir(), prefix));
try {
return await fn(tempDir);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
}
function getMacSwiftClipboardSource(): string {
return `import AppKit
import Foundation
func die(_ message: String, _ code: Int32 = 1) -> Never {
FileHandle.standardError.write(message.data(using: .utf8)!)
exit(code)
}
if CommandLine.arguments.count < 3 {
die("Usage: clipboard.swift <image|html> <path>\\n")
}
let mode = CommandLine.arguments[1]
let inputPath = CommandLine.arguments[2]
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
switch mode {
case "image":
guard let image = NSImage(contentsOfFile: inputPath) else {
die("Failed to load image: \\(inputPath)\\n")
}
if !pasteboard.writeObjects([image]) {
die("Failed to write image to clipboard\\n")
}
case "html":
let url = URL(fileURLWithPath: inputPath)
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
die("Failed to read HTML file: \\(inputPath)\\n")
}
_ = pasteboard.setData(data, forType: .html)
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
]
if let attr = try? NSAttributedString(data: data, options: options, documentAttributes: nil) {
pasteboard.setString(attr.string, forType: .string)
if let rtf = try? attr.data(
from: NSRange(location: 0, length: attr.length),
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtf]
) {
_ = pasteboard.setData(rtf, forType: .rtf)
}
} else if let html = String(data: data, encoding: .utf8) {
pasteboard.setString(html, forType: .string)
}
default:
die("Unknown mode: \\(mode)\\n")
}
`;
}
async function copyImageMac(imagePath: string): Promise<void> {
await withTempDir('copy-to-clipboard-', async (tempDir) => {
const swiftPath = path.join(tempDir, 'clipboard.swift');
await writeFile(swiftPath, getMacSwiftClipboardSource(), 'utf8');
await runCommand('swift', [swiftPath, 'image', imagePath]);
});
}
async function copyHtmlMac(htmlFilePath: string): Promise<void> {
await withTempDir('copy-to-clipboard-', async (tempDir) => {
const swiftPath = path.join(tempDir, 'clipboard.swift');
await writeFile(swiftPath, getMacSwiftClipboardSource(), 'utf8');
await runCommand('swift', [swiftPath, 'html', htmlFilePath]);
});
}
async function copyImageLinux(imagePath: string): Promise<void> {
const mime = inferImageMimeType(imagePath);
if (await commandExists('wl-copy')) {
await runCommandWithFileStdin('wl-copy', ['--type', mime], imagePath);
return;
}
if (await commandExists('xclip')) {
await runCommand('xclip', ['-selection', 'clipboard', '-t', mime, '-i', imagePath]);
return;
}
throw new Error('No clipboard tool found. Install `wl-clipboard` (wl-copy) or `xclip`.');
}
async function copyHtmlLinux(htmlFilePath: string): Promise<void> {
if (await commandExists('wl-copy')) {
await runCommandWithFileStdin('wl-copy', ['--type', 'text/html'], htmlFilePath);
return;
}
if (await commandExists('xclip')) {
await runCommand('xclip', ['-selection', 'clipboard', '-t', 'text/html', '-i', htmlFilePath]);
return;
}
throw new Error('No clipboard tool found. Install `wl-clipboard` (wl-copy) or `xclip`.');
}
async function copyImageWindows(imagePath: string): Promise<void> {
const ps = [
'param([string]$Path)',
'Add-Type -AssemblyName System.Windows.Forms',
'Add-Type -AssemblyName System.Drawing',
'$img = [System.Drawing.Image]::FromFile($Path)',
'[System.Windows.Forms.Clipboard]::SetImage($img)',
'$img.Dispose()',
].join('; ');
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps, '-Path', imagePath]);
}
async function copyHtmlWindows(htmlFilePath: string): Promise<void> {
const ps = [
'param([string]$Path)',
'Add-Type -AssemblyName System.Windows.Forms',
'$html = Get-Content -Raw -LiteralPath $Path',
'[System.Windows.Forms.Clipboard]::SetText($html, [System.Windows.Forms.TextDataFormat]::Html)',
].join('; ');
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps, '-Path', htmlFilePath]);
}
async function copyImageToClipboard(imagePathInput: string): Promise<void> {
const imagePath = resolvePath(imagePathInput);
const ext = path.extname(imagePath).toLowerCase();
if (!SUPPORTED_IMAGE_EXTS.has(ext)) {
throw new Error(
`Unsupported image type: ${ext || '(none)'} (supported: ${Array.from(SUPPORTED_IMAGE_EXTS).join(', ')})`,
);
}
if (!fs.existsSync(imagePath)) throw new Error(`File not found: ${imagePath}`);
switch (process.platform) {
case 'darwin':
await copyImageMac(imagePath);
return;
case 'linux':
await copyImageLinux(imagePath);
return;
case 'win32':
await copyImageWindows(imagePath);
return;
default:
throw new Error(`Unsupported platform: ${process.platform}`);
}
}
async function copyHtmlFileToClipboard(htmlFilePathInput: string): Promise<void> {
const htmlFilePath = resolvePath(htmlFilePathInput);
if (!fs.existsSync(htmlFilePath)) throw new Error(`File not found: ${htmlFilePath}`);
switch (process.platform) {
case 'darwin':
await copyHtmlMac(htmlFilePath);
return;
case 'linux':
await copyHtmlLinux(htmlFilePath);
return;
case 'win32':
await copyHtmlWindows(htmlFilePath);
return;
default:
throw new Error(`Unsupported platform: ${process.platform}`);
}
}
async function readStdinText(): Promise<string | null> {
if (process.stdin.isTTY) return null;
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const text = Buffer.concat(chunks).toString('utf8');
return text.length > 0 ? text : null;
}
async function copyHtmlToClipboard(args: string[]): Promise<void> {
let htmlFile: string | undefined;
const positional: string[] = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i] ?? '';
if (arg === '--help' || arg === '-h') printUsage(0);
if (arg === '--file') {
htmlFile = args[i + 1];
i += 1;
continue;
}
if (arg.startsWith('--file=')) {
htmlFile = arg.slice('--file='.length);
continue;
}
if (arg === '--') {
positional.push(...args.slice(i + 1));
break;
}
if (arg.startsWith('-')) {
throw new Error(`Unknown option: ${arg}`);
}
positional.push(arg);
}
if (htmlFile && positional.length > 0) {
throw new Error('Do not pass HTML text when using --file.');
}
if (htmlFile) {
await copyHtmlFileToClipboard(htmlFile);
return;
}
const htmlFromArgs = positional.join(' ').trim();
const htmlFromStdin = (await readStdinText())?.trim() ?? '';
const html = htmlFromArgs || htmlFromStdin;
if (!html) throw new Error('Missing HTML input. Provide a string or use --file.');
await withTempDir('copy-to-clipboard-', async (tempDir) => {
const htmlPath = path.join(tempDir, 'input.html');
await writeFile(htmlPath, html, 'utf8');
await copyHtmlFileToClipboard(htmlPath);
});
}
async function main(): Promise<void> {
const argv = process.argv.slice(2);
if (argv.length === 0) printUsage(1);
const command = argv[0];
if (command === '--help' || command === '-h') printUsage(0);
if (command === 'image') {
const imagePath = argv[1];
if (!imagePath) throw new Error('Missing image path.');
await copyImageToClipboard(imagePath);
return;
}
if (command === 'html') {
await copyHtmlToClipboard(argv.slice(1));
return;
}
throw new Error(`Unknown command: ${command}`);
}
await main().catch((err) => {
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: ${message}`);
process.exit(1);
});
+429
View File
@@ -0,0 +1,429 @@
import fs from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import https from 'node:https';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { createHash } from 'node:crypto';
interface ImageInfo {
placeholder: string;
localPath: string;
originalPath: string;
blockIndex: number;
}
interface ParsedMarkdown {
title: string;
coverImage: string | null;
contentImages: ImageInfo[];
html: string;
totalBlocks: number;
}
function parseFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return { frontmatter: {}, body: content };
const frontmatter: Record<string, string> = {};
const lines = match[1]!.split('\n');
for (const line of lines) {
const colonIdx = line.indexOf(':');
if (colonIdx > 0) {
const key = line.slice(0, colonIdx).trim();
let value = line.slice(colonIdx + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
frontmatter[key] = value;
}
}
return { frontmatter, body: match[2]! };
}
function downloadFile(url: string, destPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http;
const file = fs.createWriteStream(destPath);
const request = protocol.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (response) => {
if (response.statusCode === 301 || response.statusCode === 302) {
const redirectUrl = response.headers.location;
if (redirectUrl) {
file.close();
fs.unlinkSync(destPath);
downloadFile(redirectUrl, destPath).then(resolve).catch(reject);
return;
}
}
if (response.statusCode !== 200) {
file.close();
fs.unlinkSync(destPath);
reject(new Error(`Failed to download: ${response.statusCode}`));
return;
}
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
});
request.on('error', (err) => {
file.close();
fs.unlink(destPath, () => {});
reject(err);
});
request.setTimeout(30000, () => {
request.destroy();
reject(new Error('Download timeout'));
});
});
}
function getImageExtension(urlOrPath: string): string {
const match = urlOrPath.match(/\.(jpg|jpeg|png|gif|webp)(\?|$)/i);
return match ? match[1]!.toLowerCase() : 'png';
}
async function resolveImagePath(imagePath: string, baseDir: string, tempDir: string): Promise<string> {
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
const hash = createHash('md5').update(imagePath).digest('hex').slice(0, 8);
const ext = getImageExtension(imagePath);
const localPath = path.join(tempDir, `remote_${hash}.${ext}`);
if (!fs.existsSync(localPath)) {
console.error(`[md-to-html] Downloading: ${imagePath}`);
await downloadFile(imagePath, localPath);
}
return localPath;
}
if (path.isAbsolute(imagePath)) {
return imagePath;
}
return path.resolve(baseDir, imagePath);
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function convertMarkdownToHtml(markdown: string, imageCallback: (src: string, alt: string) => string): { html: string; totalBlocks: number } {
const lines = markdown.split('\n');
const blocks: string[] = [];
let inCodeBlock = false;
let codeBlockContent: string[] = [];
let inList = false;
let listItems: string[] = [];
let listType: 'ul' | 'ol' = 'ul';
const flushList = () => {
if (listItems.length > 0) {
const tag = listType === 'ol' ? 'ol' : 'ul';
blocks.push(`<${tag}>${listItems.map((item) => `<li>${item}</li>`).join('')}</${tag}>`);
listItems = [];
inList = false;
}
};
const processInline = (text: string): string => {
// Bold
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
text = text.replace(/__(.+?)__/g, '<strong>$1</strong>');
// Italic
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
text = text.replace(/_(.+?)_/g, '<em>$1</em>');
// Links
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
// Inline code
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
return text;
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
// Code block
if (line.startsWith('```')) {
if (inCodeBlock) {
// X doesn't support <pre><code>, convert to blockquote
const codeContent = codeBlockContent.map((l) => escapeHtml(l)).join('<br>');
blocks.push(`<blockquote>${codeContent}</blockquote>`);
codeBlockContent = [];
inCodeBlock = false;
} else {
flushList();
inCodeBlock = true;
}
continue;
}
if (inCodeBlock) {
codeBlockContent.push(line);
continue;
}
// Empty line
if (line.trim() === '') {
flushList();
continue;
}
// Image
const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/);
if (imgMatch) {
flushList();
const placeholder = imageCallback(imgMatch[2]!, imgMatch[1]!);
blocks.push(`<p>${placeholder}</p>`);
continue;
}
// Heading (H1 is title, skip it; H2-H6 become H2)
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
flushList();
const level = headingMatch[1]!.length;
if (level === 1) continue; // Skip H1, it's the title
blocks.push(`<h2>${processInline(headingMatch[2]!)}</h2>`);
continue;
}
// Blockquote
if (line.startsWith('> ')) {
flushList();
blocks.push(`<blockquote>${processInline(line.slice(2))}</blockquote>`);
continue;
}
// Unordered list
const ulMatch = line.match(/^[-*]\s+(.+)$/);
if (ulMatch) {
if (!inList || listType !== 'ul') {
flushList();
inList = true;
listType = 'ul';
}
listItems.push(processInline(ulMatch[1]!));
continue;
}
// Ordered list
const olMatch = line.match(/^\d+\.\s+(.+)$/);
if (olMatch) {
if (!inList || listType !== 'ol') {
flushList();
inList = true;
listType = 'ol';
}
listItems.push(processInline(olMatch[1]!));
continue;
}
// Horizontal rule
if (/^[-*_]{3,}\s*$/.test(line)) {
flushList();
blocks.push('<hr>');
continue;
}
// Regular paragraph
flushList();
blocks.push(`<p>${processInline(line)}</p>`);
}
flushList();
return {
html: blocks.join('\n'),
totalBlocks: blocks.length,
};
}
export async function parseMarkdown(
markdownPath: string,
options?: { coverImage?: string; title?: string; tempDir?: string },
): Promise<ParsedMarkdown> {
const content = fs.readFileSync(markdownPath, 'utf-8');
const baseDir = path.dirname(markdownPath);
const tempDir = options?.tempDir ?? path.join(os.tmpdir(), 'x-article-images');
await mkdir(tempDir, { recursive: true });
const { frontmatter, body } = parseFrontmatter(content);
// Extract title from frontmatter, option, or first H1
let title = options?.title ?? frontmatter.title ?? '';
if (!title) {
const h1Match = body.match(/^#\s+(.+)$/m);
if (h1Match) title = h1Match[1]!;
}
// Extract cover image from frontmatter or option
let coverImagePath = options?.coverImage ?? frontmatter.cover_image ?? frontmatter.coverImage ?? frontmatter.cover ?? frontmatter.image ?? frontmatter.featureImage ?? frontmatter.feature_image ?? null;
const images: Array<{ src: string; alt: string; blockIndex: number }> = [];
let imageCounter = 0;
const { html, totalBlocks } = convertMarkdownToHtml(body, (src, alt) => {
const placeholder = `[[IMAGE_PLACEHOLDER_${++imageCounter}]]`;
const currentBlockIndex = images.length; // Will be set properly after HTML generation
images.push({ src, alt, blockIndex: -1 }); // blockIndex set later
return placeholder;
});
// Update block indices by finding placeholders in HTML
const htmlLines = html.split('\n');
let blockIdx = 0;
for (const line of htmlLines) {
for (let i = 0; i < images.length; i++) {
const placeholder = `[[IMAGE_PLACEHOLDER_${i + 1}]]`;
if (line.includes(placeholder)) {
images[i]!.blockIndex = blockIdx;
}
}
blockIdx++;
}
// Resolve image paths (download remote, resolve relative)
const contentImages: ImageInfo[] = [];
let isFirstImage = true;
for (let i = 0; i < images.length; i++) {
const img = images[i]!;
const localPath = await resolveImagePath(img.src, baseDir, tempDir);
// First image becomes cover if no cover specified
if (isFirstImage && !coverImagePath) {
coverImagePath = localPath;
isFirstImage = false;
// Don't add to contentImages, it's the cover
continue;
}
isFirstImage = false;
contentImages.push({
placeholder: `[[IMAGE_PLACEHOLDER_${i + 1}]]`,
localPath,
originalPath: img.src,
blockIndex: img.blockIndex,
});
}
// Resolve cover image path
let resolvedCoverImage: string | null = null;
if (coverImagePath) {
resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir);
}
return {
title,
coverImage: resolvedCoverImage,
contentImages,
html,
totalBlocks,
};
}
function printUsage(): never {
console.log(`Convert Markdown to HTML for X Article publishing
Usage:
npx -y bun md-to-html.ts <markdown_file> [options]
Options:
--title <title> Override title from frontmatter
--cover <image> Override cover image from frontmatter
--output <json|html> Output format (default: json)
--html-only Output only the HTML content
--save-html <path> Save HTML to file
Frontmatter fields:
title: Article title (or use first H1)
cover_image: Cover image path or URL
cover: Alias for cover_image
image: Alias for cover_image
Example:
npx -y bun md-to-html.ts article.md --output json
npx -y bun md-to-html.ts article.md --html-only > /tmp/article.html
npx -y bun md-to-html.ts article.md --save-html /tmp/article.html
`);
process.exit(0);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
printUsage();
}
let markdownPath: string | undefined;
let title: string | undefined;
let coverImage: string | undefined;
let outputFormat: 'json' | 'html' = 'json';
let htmlOnly = false;
let saveHtmlPath: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--title' && args[i + 1]) {
title = args[++i];
} else if (arg === '--cover' && args[i + 1]) {
coverImage = args[++i];
} else if (arg === '--output' && args[i + 1]) {
outputFormat = args[++i] as 'json' | 'html';
} else if (arg === '--html-only') {
htmlOnly = true;
} else if (arg === '--save-html' && args[i + 1]) {
saveHtmlPath = args[++i];
} else if (!arg.startsWith('-')) {
markdownPath = arg;
}
}
if (!markdownPath) {
console.error('Error: Markdown file path required');
process.exit(1);
}
if (!fs.existsSync(markdownPath)) {
console.error(`Error: File not found: ${markdownPath}`);
process.exit(1);
}
const result = await parseMarkdown(markdownPath, { title, coverImage });
if (saveHtmlPath) {
await writeFile(saveHtmlPath, result.html, 'utf-8');
console.error(`[md-to-html] HTML saved to: ${saveHtmlPath}`);
}
if (htmlOnly) {
console.log(result.html);
} else if (outputFormat === 'html') {
console.log(result.html);
} else {
console.log(JSON.stringify(result, null, 2));
}
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
+720
View File
@@ -0,0 +1,720 @@
import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { parseMarkdown } from './md-to-html.js';
const X_ARTICLES_URL = 'https://x.com/compose/articles';
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('Unable to allocate port')));
return;
}
server.close((err) => (err ? reject(err) : resolve(address.port)));
});
});
}
function findChromeExecutable(): string | undefined {
const override = process.env.X_BROWSER_CHROME_PATH?.trim();
if (override && fs.existsSync(override)) return override;
const candidates: string[] = [];
switch (process.platform) {
case 'darwin':
candidates.push(
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
);
break;
case 'win32':
candidates.push(
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
);
break;
default:
candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser');
break;
}
for (const p of candidates) if (fs.existsSync(p)) return p;
return undefined;
}
function getDefaultProfileDir(): string {
const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
return path.join(base, 'x-browser-profile');
}
async function fetchJson<T>(url: string): Promise<T> {
const res = await fetch(url, { redirect: 'follow' });
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json() as Promise<T>;
}
async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise<string> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
} catch {}
await sleep(200);
}
throw new Error('Chrome debug port not ready');
}
class CdpConnection {
private ws: WebSocket;
private nextId = 0;
private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> | null }>();
private constructor(ws: WebSocket) {
this.ws = ws;
this.ws.addEventListener('message', (event) => {
try {
const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer);
const msg = JSON.parse(data) as { id?: number; result?: unknown; error?: { message?: string } };
if (msg.id) {
const pending = this.pending.get(msg.id);
if (pending) {
this.pending.delete(msg.id);
if (pending.timer) clearTimeout(pending.timer);
if (msg.error?.message) pending.reject(new Error(msg.error.message));
else pending.resolve(msg.result);
}
}
} catch {}
});
this.ws.addEventListener('close', () => {
for (const [id, pending] of this.pending.entries()) {
this.pending.delete(id);
if (pending.timer) clearTimeout(pending.timer);
pending.reject(new Error('CDP connection closed'));
}
});
}
static async connect(url: string, timeoutMs: number): Promise<CdpConnection> {
const ws = new WebSocket(url);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('CDP connection timeout')), timeoutMs);
ws.addEventListener('open', () => { clearTimeout(timer); resolve(); });
ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('CDP connection failed')); });
});
return new CdpConnection(ws);
}
async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: { sessionId?: string; timeoutMs?: number }): Promise<T> {
const id = ++this.nextId;
const message: Record<string, unknown> = { id, method };
if (params) message.params = params;
if (options?.sessionId) message.sessionId = options.sessionId;
const timeoutMs = options?.timeoutMs ?? 30_000;
return new Promise<T>((resolve, reject) => {
const timer = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) : null;
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, timer });
this.ws.send(JSON.stringify(message));
});
}
close(): void {
try { this.ws.close(); } catch {}
}
}
function copyImageToClipboard(imagePath: string): boolean {
const scriptDir = path.dirname(new URL(import.meta.url).pathname);
const copyScript = path.join(scriptDir, 'copy-to-clipboard.ts');
const result = spawnSync('npx', ['-y', 'bun', copyScript, 'image', imagePath], { stdio: 'inherit' });
return result.status === 0;
}
function copyHtmlToClipboard(htmlPath: string): boolean {
const scriptDir = path.dirname(new URL(import.meta.url).pathname);
const copyScript = path.join(scriptDir, 'copy-to-clipboard.ts');
const result = spawnSync('npx', ['-y', 'bun', copyScript, 'html', '--file', htmlPath], { stdio: 'inherit' });
return result.status === 0;
}
function sendRealPasteKeystroke(retries = 3): boolean {
if (process.platform !== 'darwin') {
console.log('[x-article] Real keystroke only supported on macOS');
return false;
}
// Use osascript to send Cmd+V to frontmost application (Chrome)
const script = `
tell application "System Events"
keystroke "v" using command down
end tell
`;
for (let i = 0; i < retries; i++) {
const result = spawnSync('osascript', ['-e', script], { stdio: 'pipe' });
if (result.status === 0) {
return true;
}
// Wait a bit before retry
if (i < retries - 1) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500);
}
}
return false;
}
interface ArticleOptions {
markdownPath: string;
coverImage?: string;
title?: string;
submit?: boolean;
profileDir?: string;
chromePath?: string;
}
export async function publishArticle(options: ArticleOptions): Promise<void> {
const { markdownPath, submit = false, profileDir = getDefaultProfileDir() } = options;
console.log('[x-article] Parsing markdown...');
const parsed = await parseMarkdown(markdownPath, {
title: options.title,
coverImage: options.coverImage,
});
console.log(`[x-article] Title: ${parsed.title}`);
console.log(`[x-article] Cover: ${parsed.coverImage ?? 'none'}`);
console.log(`[x-article] Content images: ${parsed.contentImages.length}`);
// Save HTML to temp file
const htmlPath = path.join(os.tmpdir(), 'x-article-content.html');
await writeFile(htmlPath, parsed.html, 'utf-8');
console.log(`[x-article] HTML saved to: ${htmlPath}`);
const chromePath = options.chromePath ?? findChromeExecutable();
if (!chromePath) throw new Error('Chrome not found');
await mkdir(profileDir, { recursive: true });
const port = await getFreePort();
console.log(`[x-article] Launching Chrome...`);
const chrome = spawn(chromePath, [
`--remote-debugging-port=${port}`,
`--user-data-dir=${profileDir}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-blink-features=AutomationControlled',
'--start-maximized',
X_ARTICLES_URL,
], { stdio: 'ignore' });
let cdp: CdpConnection | null = null;
try {
const wsUrl = await waitForChromeDebugPort(port, 30_000);
cdp = await CdpConnection.connect(wsUrl, 30_000);
// Get page target
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('x.com'));
if (!pageTarget) {
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: X_ARTICLES_URL });
pageTarget = { targetId, url: X_ARTICLES_URL, type: 'page' };
}
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true });
await cdp.send('Page.enable', {}, { sessionId });
await cdp.send('Runtime.enable', {}, { sessionId });
await cdp.send('DOM.enable', {}, { sessionId });
console.log('[x-article] Waiting for articles page...');
await sleep(3000);
// Wait for and click "create" button
const waitForElement = async (selector: string, timeoutMs = 60_000): Promise<boolean> => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('${selector}')`,
returnByValue: true,
}, { sessionId });
if (result.result.value) return true;
await sleep(500);
}
return false;
};
const clickElement = async (selector: string): Promise<boolean> => {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `(() => { const el = document.querySelector('${selector}'); if (el) { el.click(); return true; } return false; })()`,
returnByValue: true,
}, { sessionId });
return result.result.value;
};
const typeText = async (selector: string, text: string): Promise<void> => {
await cdp!.send('Runtime.evaluate', {
expression: `(() => {
const el = document.querySelector('${selector}');
if (el) {
el.focus();
document.execCommand('insertText', false, ${JSON.stringify(text)});
}
})()`,
}, { sessionId });
};
const pressKey = async (key: string, modifiers = 0): Promise<void> => {
await cdp!.send('Input.dispatchKeyEvent', {
type: 'keyDown',
key,
code: `Key${key.toUpperCase()}`,
modifiers,
windowsVirtualKeyCode: key.toUpperCase().charCodeAt(0),
}, { sessionId });
await cdp!.send('Input.dispatchKeyEvent', {
type: 'keyUp',
key,
code: `Key${key.toUpperCase()}`,
modifiers,
windowsVirtualKeyCode: key.toUpperCase().charCodeAt(0),
}, { sessionId });
};
const pasteFromClipboard = async (): Promise<void> => {
const modifiers = process.platform === 'darwin' ? 4 : 2; // Meta or Ctrl
await pressKey('v', modifiers);
};
// Check if we're on the articles list page (has Write button)
console.log('[x-article] Looking for Write button...');
const writeButtonFound = await waitForElement('[data-testid="empty_state_button_text"]', 10_000);
if (writeButtonFound) {
console.log('[x-article] Clicking Write button...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="empty_state_button_text"]')?.click()`,
}, { sessionId });
await sleep(2000);
}
// Wait for editor (title textarea)
console.log('[x-article] Waiting for editor...');
const editorFound = await waitForElement('textarea[placeholder="Add a title"], textarea[name="Article Title"]', 30_000);
if (!editorFound) {
console.log('[x-article] Editor not found. Please ensure you have X Premium and are logged in.');
await sleep(60_000);
throw new Error('Editor not found');
}
// Upload cover image
if (parsed.coverImage) {
console.log('[x-article] Uploading cover image...');
// Click "Add photos or video" button
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[aria-label="Add photos or video"]')?.click()`,
}, { sessionId });
await sleep(500);
// Use file input directly
const { root } = await cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {}, { sessionId });
const { nodeId } = await cdp.send<{ nodeId: number }>('DOM.querySelector', {
nodeId: root.nodeId,
selector: '[data-testid="fileInput"], input[type="file"][accept*="image"]',
}, { sessionId });
if (nodeId) {
await cdp.send('DOM.setFileInputFiles', {
nodeId,
files: [parsed.coverImage],
}, { sessionId });
console.log('[x-article] Cover image file set');
// Wait for Apply button to appear and click it
console.log('[x-article] Waiting for Apply button...');
const applyFound = await waitForElement('[data-testid="applyButton"]', 15_000);
if (applyFound) {
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="applyButton"]')?.click()`,
}, { sessionId });
console.log('[x-article] Cover image applied');
await sleep(1000);
} else {
console.log('[x-article] Apply button not found, continuing...');
}
}
}
// Fill title using keyboard input
if (parsed.title) {
console.log('[x-article] Filling title...');
// Focus title input
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('textarea[placeholder="Add a title"], textarea[name="Article Title"]')?.focus()`,
}, { sessionId });
await sleep(200);
// Type title character by character using insertText
await cdp.send('Input.insertText', { text: parsed.title }, { sessionId });
await sleep(300);
// Tab out to trigger save
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 }, { sessionId });
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 }, { sessionId });
await sleep(500);
}
// Insert HTML content
console.log('[x-article] Inserting content...');
// Read HTML content
const htmlContent = fs.readFileSync(htmlPath, 'utf-8');
// Focus on DraftEditor body
await cdp.send('Runtime.evaluate', {
expression: `(() => {
const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');
if (editor) {
editor.focus();
editor.click();
return true;
}
return false;
})()`,
}, { sessionId });
await sleep(300);
// Method 1: Simulate paste event with HTML data
console.log('[x-article] Attempting to insert HTML via paste event...');
const pasteResult = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `(() => {
const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');
if (!editor) return false;
const html = ${JSON.stringify(htmlContent)};
// Create a paste event with HTML data
const dt = new DataTransfer();
dt.setData('text/html', html);
dt.setData('text/plain', html.replace(/<[^>]*>/g, ''));
const pasteEvent = new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: dt
});
editor.dispatchEvent(pasteEvent);
return true;
})()`,
returnByValue: true,
}, { sessionId });
await sleep(1000);
// Check if content was inserted
const contentCheck = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {
expression: `document.querySelector('.DraftEditor-editorContainer [data-contents="true"]')?.innerText?.length || 0`,
returnByValue: true,
}, { sessionId });
if (contentCheck.result.value > 50) {
console.log(`[x-article] Content inserted successfully (${contentCheck.result.value} chars)`);
} else {
console.log('[x-article] Paste event may not have worked, trying insertHTML...');
// Method 2: Use execCommand insertHTML
await cdp.send('Runtime.evaluate', {
expression: `(() => {
const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');
if (!editor) return false;
editor.focus();
document.execCommand('insertHTML', false, ${JSON.stringify(htmlContent)});
return true;
})()`,
}, { sessionId });
await sleep(1000);
// Check again
const check2 = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {
expression: `document.querySelector('.DraftEditor-editorContainer [data-contents="true"]')?.innerText?.length || 0`,
returnByValue: true,
}, { sessionId });
if (check2.result.value > 50) {
console.log(`[x-article] Content inserted via execCommand (${check2.result.value} chars)`);
} else {
console.log('[x-article] Auto-insert failed. HTML copied to clipboard - please paste manually (Cmd+V)');
copyHtmlToClipboard(htmlPath);
// Wait for manual paste
console.log('[x-article] Waiting 30s for manual paste...');
await sleep(30_000);
}
}
// Insert content images (reverse order to maintain positions)
if (parsed.contentImages.length > 0) {
console.log('[x-article] Inserting content images...');
// First, check what placeholders exist in the editor
const editorContent = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
expression: `document.querySelector('.DraftEditor-editorContainer [data-contents="true"]')?.innerText || ''`,
returnByValue: true,
}, { sessionId });
console.log('[x-article] Checking for placeholders in content...');
for (const img of parsed.contentImages) {
if (editorContent.result.value.includes(img.placeholder)) {
console.log(`[x-article] Found: ${img.placeholder}`);
} else {
console.log(`[x-article] NOT found: ${img.placeholder}`);
}
}
// Process images in sequential order (1, 2, 3, ...)
const sortedImages = [...parsed.contentImages].sort((a, b) => a.blockIndex - b.blockIndex);
for (let i = 0; i < sortedImages.length; i++) {
const img = sortedImages[i]!;
console.log(`[x-article] [${i + 1}/${sortedImages.length}] Inserting image at placeholder: ${img.placeholder}`);
// Find, scroll to, and select the placeholder text in DraftEditor
const placeholderFound = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `(() => {
const editor = document.querySelector('.DraftEditor-editorContainer [data-contents="true"]');
if (!editor) return false;
const placeholder = ${JSON.stringify(img.placeholder)};
// Search through all text nodes in the editor
const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false);
let node;
while ((node = walker.nextNode())) {
const text = node.textContent || '';
const idx = text.indexOf(placeholder);
if (idx !== -1) {
// Found the placeholder - scroll to it first
const parentElement = node.parentElement;
if (parentElement) {
parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// Select it
const range = document.createRange();
range.setStart(node, idx);
range.setEnd(node, idx + placeholder.length);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
return true;
}
}
return false;
})()`,
returnByValue: true,
}, { sessionId });
// Wait for scroll animation
await sleep(300);
if (!placeholderFound.result.value) {
console.warn(`[x-article] Placeholder not found in DOM: ${img.placeholder}`);
continue;
}
console.log(`[x-article] Placeholder selected, copying image: ${path.basename(img.localPath)}`);
// Copy image to clipboard
if (!copyImageToClipboard(img.localPath)) {
console.warn(`[x-article] Failed to copy image to clipboard`);
continue;
}
await sleep(300);
// Delete placeholder by pressing Enter (placeholder is already selected)
console.log(`[x-article] Deleting placeholder with Enter...`);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId });
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId });
await sleep(200);
// Paste image
console.log(`[x-article] Pasting image...`);
if (sendRealPasteKeystroke()) {
console.log(`[x-article] Image pasted: ${path.basename(img.localPath)}`);
} else {
console.warn(`[x-article] Failed to paste image`);
}
// Wait for image to upload
console.log(`[x-article] Waiting for upload...`);
await sleep(4000);
}
console.log('[x-article] All images processed.');
}
// Before preview: blur editor to trigger save
console.log('[x-article] Triggering content save...');
await cdp.send('Runtime.evaluate', {
expression: `(() => {
// Blur editor to trigger any pending saves
const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');
if (editor) {
editor.blur();
}
// Also click elsewhere to ensure focus is lost
document.body.click();
})()`,
}, { sessionId });
await sleep(1500);
// Click Preview button
console.log('[x-article] Opening preview...');
const previewClicked = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `(() => {
// Try multiple selectors for preview button
const previewLink = document.querySelector('a[href*="/preview"]')
|| document.querySelector('[data-testid="previewButton"]')
|| document.querySelector('button[aria-label*="preview" i]');
if (previewLink) {
previewLink.click();
return true;
}
return false;
})()`,
returnByValue: true,
}, { sessionId });
if (previewClicked.result.value) {
console.log('[x-article] Preview opened');
await sleep(3000);
} else {
console.log('[x-article] Preview button not found');
}
// Check for publish button
if (submit) {
console.log('[x-article] Publishing...');
await cdp.send('Runtime.evaluate', {
expression: `(() => {
const publishBtn = document.querySelector('[data-testid="publishButton"], button[aria-label*="publish" i], button[aria-label*="发布" i]');
if (publishBtn && !publishBtn.disabled) {
publishBtn.click();
return true;
}
return false;
})()`,
}, { sessionId });
await sleep(3000);
console.log('[x-article] Article published!');
} else {
console.log('[x-article] Article composed (draft mode).');
console.log('[x-article] Browser will stay open for 60 seconds for review...');
await sleep(60_000);
}
} finally {
if (cdp) {
try { await cdp.send('Browser.close', {}, { timeoutMs: 5_000 }); } catch {}
cdp.close();
}
setTimeout(() => { if (!chrome.killed) try { chrome.kill('SIGKILL'); } catch {} }, 2_000).unref?.();
try { chrome.kill('SIGTERM'); } catch {}
}
}
function printUsage(): never {
console.log(`Publish Markdown article to X (Twitter) Articles
Usage:
npx -y bun x-article.ts <markdown_file> [options]
Options:
--title <title> Override title
--cover <image> Override cover image
--submit Actually publish (default: draft only)
--profile <dir> Chrome profile directory
--help Show this help
Markdown frontmatter:
---
title: My Article Title
cover_image: /path/to/cover.jpg
---
Example:
npx -y bun x-article.ts article.md
npx -y bun x-article.ts article.md --cover ./hero.png
npx -y bun x-article.ts article.md --submit
`);
process.exit(0);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
printUsage();
}
let markdownPath: string | undefined;
let title: string | undefined;
let coverImage: string | undefined;
let submit = false;
let profileDir: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--title' && args[i + 1]) {
title = args[++i];
} else if (arg === '--cover' && args[i + 1]) {
coverImage = args[++i];
} else if (arg === '--submit') {
submit = true;
} else if (arg === '--profile' && args[i + 1]) {
profileDir = args[++i];
} else if (!arg.startsWith('-')) {
markdownPath = arg;
}
}
if (!markdownPath) {
console.error('Error: Markdown file path required');
process.exit(1);
}
if (!fs.existsSync(markdownPath)) {
console.error(`Error: File not found: ${markdownPath}`);
process.exit(1);
}
await publishArticle({ markdownPath, title, coverImage, submit, profileDir });
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
+390
View File
@@ -0,0 +1,390 @@
import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import { mkdir } from 'node:fs/promises';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
const X_COMPOSE_URL = 'https://x.com/compose/post';
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('Unable to allocate a free TCP port.')));
return;
}
const port = address.port;
server.close((err) => {
if (err) reject(err);
else resolve(port);
});
});
});
}
function findChromeExecutable(): string | undefined {
const override = process.env.X_BROWSER_CHROME_PATH?.trim();
if (override && fs.existsSync(override)) return override;
const candidates: string[] = [];
switch (process.platform) {
case 'darwin':
candidates.push(
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
);
break;
case 'win32':
candidates.push(
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
);
break;
default:
candidates.push(
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/snap/bin/chromium',
'/usr/bin/microsoft-edge',
);
break;
}
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
return undefined;
}
function getDefaultProfileDir(): string {
const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
return path.join(base, 'x-browser-profile');
}
async function fetchJson<T = unknown>(url: string): Promise<T> {
const res = await fetch(url, { redirect: 'follow' });
if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`);
return (await res.json()) as T;
}
async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise<string> {
const start = Date.now();
let lastError: unknown = null;
while (Date.now() - start < timeoutMs) {
try {
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
lastError = new Error('Missing webSocketDebuggerUrl');
} catch (error) {
lastError = error;
}
await sleep(200);
}
throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}
class CdpConnection {
private ws: WebSocket;
private nextId = 0;
private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> | null }>();
private eventHandlers = new Map<string, Set<(params: unknown) => void>>();
private constructor(ws: WebSocket) {
this.ws = ws;
this.ws.addEventListener('message', (event) => {
try {
const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer);
const msg = JSON.parse(data) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: { message?: string } };
if (msg.method) {
const handlers = this.eventHandlers.get(msg.method);
if (handlers) handlers.forEach((h) => h(msg.params));
}
if (msg.id) {
const pending = this.pending.get(msg.id);
if (pending) {
this.pending.delete(msg.id);
if (pending.timer) clearTimeout(pending.timer);
if (msg.error?.message) pending.reject(new Error(msg.error.message));
else pending.resolve(msg.result);
}
}
} catch {}
});
this.ws.addEventListener('close', () => {
for (const [id, pending] of this.pending.entries()) {
this.pending.delete(id);
if (pending.timer) clearTimeout(pending.timer);
pending.reject(new Error('CDP connection closed.'));
}
});
}
static async connect(url: string, timeoutMs: number): Promise<CdpConnection> {
const ws = new WebSocket(url);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('CDP connection timeout.')), timeoutMs);
ws.addEventListener('open', () => { clearTimeout(timer); resolve(); });
ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('CDP connection failed.')); });
});
return new CdpConnection(ws);
}
on(method: string, handler: (params: unknown) => void): void {
if (!this.eventHandlers.has(method)) this.eventHandlers.set(method, new Set());
this.eventHandlers.get(method)!.add(handler);
}
async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: { sessionId?: string; timeoutMs?: number }): Promise<T> {
const id = ++this.nextId;
const message: Record<string, unknown> = { id, method };
if (params) message.params = params;
if (options?.sessionId) message.sessionId = options.sessionId;
const timeoutMs = options?.timeoutMs ?? 15_000;
const result = await new Promise<unknown>((resolve, reject) => {
const timer = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) : null;
this.pending.set(id, { resolve, reject, timer });
this.ws.send(JSON.stringify(message));
});
return result as T;
}
close(): void {
try { this.ws.close(); } catch {}
}
}
interface XBrowserOptions {
text?: string;
images?: string[];
submit?: boolean;
timeoutMs?: number;
profileDir?: string;
chromePath?: string;
}
export async function postToX(options: XBrowserOptions): Promise<void> {
const { text, images = [], submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;
const chromePath = options.chromePath ?? findChromeExecutable();
if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.');
await mkdir(profileDir, { recursive: true });
const port = await getFreePort();
console.log(`[x-browser] Launching Chrome (profile: ${profileDir})`);
const chrome = spawn(chromePath, [
`--remote-debugging-port=${port}`,
`--user-data-dir=${profileDir}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-blink-features=AutomationControlled',
'--start-maximized',
X_COMPOSE_URL,
], { stdio: 'ignore' });
let cdp: CdpConnection | null = null;
try {
const wsUrl = await waitForChromeDebugPort(port, 30_000);
cdp = await CdpConnection.connect(wsUrl, 30_000);
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('x.com'));
if (!pageTarget) {
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: X_COMPOSE_URL });
pageTarget = { targetId, url: X_COMPOSE_URL, type: 'page' };
}
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true });
await cdp.send('Page.enable', {}, { sessionId });
await cdp.send('Runtime.enable', {}, { sessionId });
await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId });
console.log('[x-browser] Waiting for X editor...');
await sleep(3000);
const waitForEditor = async (): Promise<boolean> => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('[data-testid="tweetTextarea_0"]')`,
returnByValue: true,
}, { sessionId });
if (result.result.value) return true;
await sleep(1000);
}
return false;
};
const editorFound = await waitForEditor();
if (!editorFound) {
console.log('[x-browser] Editor not found. Please log in to X in the browser window.');
console.log('[x-browser] Waiting for login...');
const loggedIn = await waitForEditor();
if (!loggedIn) throw new Error('Timed out waiting for X editor. Please log in first.');
}
if (text) {
console.log('[x-browser] Typing text...');
await cdp.send('Runtime.evaluate', {
expression: `
const editor = document.querySelector('[data-testid="tweetTextarea_0"]');
if (editor) {
editor.focus();
document.execCommand('insertText', false, ${JSON.stringify(text)});
}
`,
}, { sessionId });
await sleep(500);
}
for (const imagePath of images) {
if (!fs.existsSync(imagePath)) {
console.warn(`[x-browser] Image not found: ${imagePath}`);
continue;
}
console.log(`[x-browser] Pasting image: ${imagePath}`);
const scriptDir = path.dirname(new URL(import.meta.url).pathname);
const copyScript = path.join(scriptDir, 'copy-to-clipboard.ts');
const result = spawnSync('npx', ['-y', 'bun', copyScript, 'image', imagePath], { stdio: 'inherit' });
if (result.status !== 0) {
console.warn(`[x-browser] Failed to copy image to clipboard: ${imagePath}`);
continue;
}
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetTextarea_0"]')?.focus()`,
}, { sessionId });
const modifiers = process.platform === 'darwin' ? 4 : 2;
await cdp.send('Input.dispatchKeyEvent', {
type: 'keyDown',
key: 'v',
code: 'KeyV',
modifiers,
windowsVirtualKeyCode: 86,
}, { sessionId });
await cdp.send('Input.dispatchKeyEvent', {
type: 'keyUp',
key: 'v',
code: 'KeyV',
modifiers,
windowsVirtualKeyCode: 86,
}, { sessionId });
console.log('[x-browser] Waiting for image upload...');
await sleep(3000);
}
if (submit) {
console.log('[x-browser] Submitting post...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,
}, { sessionId });
await sleep(2000);
console.log('[x-browser] Post submitted!');
} else {
console.log('[x-browser] Post composed (preview mode). Add --submit to post.');
console.log('[x-browser] Browser will stay open for 30 seconds for preview...');
await sleep(30_000);
}
} finally {
if (cdp) {
try { await cdp.send('Browser.close', {}, { timeoutMs: 5_000 }); } catch {}
cdp.close();
}
setTimeout(() => {
if (!chrome.killed) try { chrome.kill('SIGKILL'); } catch {}
}, 2_000).unref?.();
try { chrome.kill('SIGTERM'); } catch {}
}
}
function printUsage(): never {
console.log(`Post to X (Twitter) using real Chrome browser
Usage:
npx -y bun x-browser.ts [options] [text]
Options:
--image <path> Add image (can be repeated, max 4)
--submit Actually post (default: preview only)
--profile <dir> Chrome profile directory
--help Show this help
Examples:
npx -y bun x-browser.ts "Hello from CLI!"
npx -y bun x-browser.ts "Check this out" --image ./screenshot.png
npx -y bun x-browser.ts "Post it!" --image a.png --image b.png --submit
`);
process.exit(0);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) printUsage();
const images: string[] = [];
let submit = false;
let profileDir: string | undefined;
const textParts: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--image' && args[i + 1]) {
images.push(args[++i]!);
} else if (arg === '--submit') {
submit = true;
} else if (arg === '--profile' && args[i + 1]) {
profileDir = args[++i];
} else if (!arg.startsWith('-')) {
textParts.push(arg);
}
}
const text = textParts.join(' ').trim() || undefined;
if (!text && images.length === 0) {
console.error('Error: Provide text or at least one image.');
process.exit(1);
}
await postToX({ text, images, submit, profileDir });
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
+383
View File
@@ -0,0 +1,383 @@
---
name: slide-deck
description: Generate professional slide deck images from content. Creates comprehensive outlines with style instructions, then generates individual slide images. Use when user asks to "create slides", "make a presentation", "generate deck", or "slide deck".
---
# Slide Deck Generator
Transform content into professional slide deck with comprehensive outlines and generated slide images.
## Usage
```bash
# From markdown file
/slide-deck path/to/article.md
# With style preference
/slide-deck path/to/article.md --style corporate
/slide-deck path/to/article.md --style playful
/slide-deck path/to/article.md --style technical
# With audience specification
/slide-deck path/to/article.md --audience beginners
/slide-deck path/to/article.md --audience executives
# With language
/slide-deck path/to/article.md --lang zh
/slide-deck path/to/article.md --lang en
# Outline only (no image generation)
/slide-deck path/to/article.md --outline-only
# Direct content input
/slide-deck
[paste content]
```
## Options
| Option | Description |
|--------|-------------|
| `--style <name>` | Visual style preset (see Style Gallery) |
| `--audience <type>` | Target audience level |
| `--lang <code>` | Output language (en, zh, etc.) |
| `--slides <number>` | Target slide count (max 20) |
| `--outline-only` | Generate outline only, skip image generation |
## Style Gallery
### 1. `editorial` (Default)
Clean, sophisticated, minimalist
- **Aesthetic**: High-end journal, architectural blueprints
- **Colors**: Off-white background (#F8F7F5), dark slate text (#2F3542), blue accents (#007AFF)
- **Best for**: Thought leadership, research presentations
### 2. `corporate`
Professional, trustworthy, polished
- **Aesthetic**: Clean lines, structured layouts, business-appropriate
- **Colors**: Navy (#1E3A5F), white, gold accents (#C9A227)
- **Best for**: Business presentations, investor decks, reports
### 3. `technical`
Precise, data-driven, analytical
- **Aesthetic**: Blueprint style, schematic diagrams, grid layouts
- **Colors**: Dark charcoal (#1A1A2E), cyan accents (#00D4FF), light gray text (#E8E8E8)
- **Best for**: Technical documentation, engineering presentations
### 4. `playful`
Bold, energetic, engaging
- **Aesthetic**: Dynamic shapes, vibrant colors, creative layouts
- **Colors**: Warm white (#FFFDF7), deep purple (#4A1D96), coral (#FF6B6B)
- **Best for**: Creative pitches, educational content, workshops
### 5. `minimal`
Ultra-clean, zen-like, focused
- **Aesthetic**: Maximum whitespace, single focal points, elegant typography
- **Colors**: Black, white, single accent color
- **Best for**: Keynotes, philosophical content, product launches
### 6. `storytelling`
Narrative-driven, cinematic, immersive
- **Aesthetic**: Full-bleed imagery, dramatic typography, chapter-like flow
- **Colors**: Rich charcoal (#2D2D2D), warm white (#FAF9F6), gold (#D4AF37)
- **Best for**: Case studies, journey narratives, brand stories
## Audience Presets
| Audience | Approach |
|----------|----------|
| `beginners` | Step-by-step, more context, simpler visuals |
| `intermediate` | Balanced detail, some assumed knowledge |
| `experts` | Dense information, technical depth, less hand-holding |
| `executives` | High-level insights, key metrics, strategic focus |
| `general` | Accessible language, broad appeal, clear takeaways |
## File Management
### With Article Path
```
path/to/
├── article.md
└── slide-deck/
├── outline.md
├── prompts/
│ ├── 01-cover.md
│ ├── 02-content-1.md
│ └── ...
├── 01-cover.png
├── 02-content-1.png
└── ...
```
### Without Article Path
```
./slide-deck-outputs/YYYY-MM-DD/[topic-slug]/
├── outline.md
├── prompts/
│ ├── 01-cover.md
│ └── ...
├── 01-cover.png
└── ...
```
## Workflow
### Step 1: Analyze Content & Select Style
1. Read source content
2. If `--style` specified, use that style
3. Otherwise, analyze content for style signals:
| Content Signals | Selected Style |
|----------------|----------------|
| AI, coding, tech, digital, algorithm, data | `technical` |
| Business, strategy, investment, corporate | `corporate` |
| Personal story, journey, narrative, emotion | `storytelling` |
| Simple, zen, focus, essential, one idea | `minimal` |
| Fun, creative, workshop, educational | `playful` |
| Research, analysis, thought leadership | `editorial` |
4. Extract key information:
- Core narrative and key messages
- Important data points and statistics
- Logical flow and structure
- Target audience signals
### Step 2: Generate Outline
Create outline with `STYLE_INSTRUCTIONS` block and slide specifications.
**Outline Format**:
```markdown
# Slide Deck Outline: [Topic]
**Source**: [source file or "Direct input"]
**Style**: [selected style]
**Audience**: [target audience]
**Language**: [output language]
**Slide Count**: N slides
**Generated**: YYYY-MM-DD HH:mm
---
<STYLE_INSTRUCTIONS>
Design Aesthetic: [Overall style description]
Background Color: [Description and Hex Code]
Primary Font: [Font name for Headlines]
Secondary Font: [Font name for Body copy]
Color Palette:
Primary Text Color: [Hex Code]
Primary Accent Color: [Hex Code]
Visual Elements: [Lines, shapes, imagery style, etc.]
</STYLE_INSTRUCTIONS>
---
## Slide 1: [Descriptive Title]
**Position**: Cover
**Filename**: 01-cover.png
// NARRATIVE GOAL
[Storytelling purpose within the overall arc]
// KEY CONTENT
Headline: [Main message - narrative, not "Title: Subtitle" format]
Sub-headline: [Supporting context]
Body:
- [Key point 1 with specific data from source]
- [Key point 2 with specific data from source]
// VISUAL
[Detailed description of imagery, charts, graphics, or abstract visuals]
// LAYOUT
[Composition, hierarchy, spatial arrangement, focus points]
---
## Slide 2: [First Content]
...
## Slide N: [Back Cover]
...
```
**Required Slide Structure**:
1. **Slide 1**: Cover Slide (poster-style, heroic typography)
2. **Slides 2-N-1**: Content slides (consistent internal style)
3. **Slide N**: Back Cover (closing statement, not "Thank You")
### Step 3: Save Outline
Save outline as `outline.md` in target directory.
If `--outline-only` flag is set, stop here.
### Step 4: Create Prompt Files
For each slide, create a style-specific prompt file.
**Prompt Format**:
```markdown
Slide theme: [slide title]
Style: [style name]
Position: [cover/content/back-cover]
Visual composition:
- Main visual: [style-appropriate description from VISUAL section]
- Layout: [from LAYOUT section]
- Decorative elements: [style-specific decorations]
Color scheme:
- Background: [style background color]
- Primary text: [style text color]
- Accent: [style accent color]
Text content:
- Headline: [headline text]
- Sub-headline: [sub-headline if any]
- Body points: [bullet points if any]
Style notes: [specific style characteristics to emphasize]
```
### Step 5: Generate Images
For each slide, generate using:
```bash
/gemini-web --promptfiles [SKILL_ROOT]/skills/slide-deck/prompts/system.md [TARGET_DIR]/prompts/01-cover.md --image [TARGET_DIR]/01-cover.png
```
Generation flow:
1. Generate images sequentially
2. After each image, output progress: "Generated X/N"
3. On failure, auto-retry once
4. If retry fails, log reason, continue to next
### Step 6: Completion Report
```
Slide Deck Generated!
Topic: [topic]
Style: [style name]
Audience: [audience]
Location: [directory path]
Slides: N total
- 01-cover.png ✓ Cover
- 02-content-1.png ✓ Content
- 03-content-2.png ✓ Content
- ...
- 0N-back-cover.png ✓ Back Cover
Outline: outline.md
[If any failures]
Failed:
- 0X-slide-name.png: [failure reason]
```
## Style Reference Details
### editorial
```
Design Aesthetic: Clean, sophisticated, minimalist editorial inspired by architectural blueprints and high-end technical journals
Background Color: Subtle textured off-white #F8F7F5
Primary Font: Neue Haas Grotesk Display Pro (bold headlines)
Secondary Font: Tiempos Text (body copy, annotations)
Primary Text Color: Dark slate grey #2F3542
Primary Accent Color: Intelligent blue #007AFF
Visual Elements: Thin precise line work, schematic diagrams, clean vectors, spacious layouts
```
### corporate
```
Design Aesthetic: Professional, trustworthy, structured business presentation
Background Color: Clean white #FFFFFF with navy accents
Primary Font: Inter (headlines)
Secondary Font: Source Sans Pro (body)
Primary Text Color: Navy #1E3A5F
Primary Accent Color: Gold #C9A227
Visual Elements: Clean charts, professional icons, structured grids, subtle shadows
```
### technical
```
Design Aesthetic: Blueprint-style, precise, data-driven analytical presentation
Background Color: Dark charcoal #1A1A2E
Primary Font: JetBrains Mono (headlines)
Secondary Font: IBM Plex Sans (body)
Primary Text Color: Light gray #E8E8E8
Primary Accent Color: Cyan #00D4FF
Visual Elements: Grid overlays, circuit patterns, data visualizations, monospace code blocks
```
### playful
```
Design Aesthetic: Bold, energetic, creative with dynamic visual interest
Background Color: Warm white #FFFDF7
Primary Font: Poppins (headlines)
Secondary Font: Nunito (body)
Primary Text Color: Deep purple #4A1D96
Primary Accent Color: Vibrant coral #FF6B6B
Visual Elements: Rounded shapes, gradients, illustrations, dynamic compositions, bright pops
```
### minimal
```
Design Aesthetic: Ultra-clean, zen-like focus with elegant restraint
Background Color: Pure white #FFFFFF
Primary Font: Helvetica Neue (headlines)
Secondary Font: Garamond (body)
Primary Text Color: Pure black #000000
Primary Accent Color: Single accent (content-derived)
Visual Elements: Maximum whitespace, single focal points, hairline rules, elegant typography
```
### storytelling
```
Design Aesthetic: Cinematic, narrative-driven with immersive visual flow
Background Color: Rich charcoal #2D2D2D or full-bleed imagery
Primary Font: Playfair Display (headlines)
Secondary Font: Lora (body)
Primary Text Color: Warm white #FAF9F6
Primary Accent Color: Warm gold #D4AF37
Visual Elements: Full-bleed photos, dramatic typography, chapter markers, emotional imagery
```
## Notes
### Design Philosophy
- Deck is designed for **reading and sharing**, not live presentation
- Structure should be self-explanatory without a presenter
- Include enough context for visuals to be understood standalone
- Err on the side of audience having **more expertise** than expected
### Content Rules
- Maximum 20 slides per deck
- Every data point must trace to source material
- All details in prompts - image generator has no access to source
### Style Rules
- Avoid AI-generated clichés ("It wasn't just X, it was Y")
- Use narrative headlines, not "Title: Subtitle" format
- Cover and Back Cover should be visually distinct (poster-style)
- Back Cover should be meaningful closure, not "Thank You" or "Questions?"
### Prohibited
- Never include photorealistic images of prominent individuals
- Never include placeholder slides for author name, date, etc.
### Image Generation
- Image generation typically takes 10-30 seconds per slide
- Auto-retry once on generation failure
- Use cartoon alternatives for sensitive public figures
- Output language matches content language
- Maintain style consistency across all slides
+40
View File
@@ -0,0 +1,40 @@
Create a presentation slide image following these guidelines:
## Image Specifications
- **Type**: Presentation slide
- **Orientation**: Landscape (horizontal)
- **Aspect Ratio**: 16:9
- **Style**: Hand-drawn illustration with professional typography
## Core Principles
- Hand-drawn quality throughout - NO realistic or photographic elements
- If content involves sensitive or copyrighted figures, create stylistically similar alternatives - DO NOT refuse to generate
- Clear visual hierarchy: headline dominates, supporting content secondary
- Ample whitespace, avoid cluttered layouts
- Professional presentation aesthetic
## Text Style (CRITICAL)
- **ALL text MUST be hand-drawn style**
- Headlines: Large, bold, prominent
- Body text: Clean, readable, well-spaced
- Use visual emphasis for key terms (color, size, underline)
- **DO NOT use realistic or computer-generated fonts**
## Layout Principles
- Consistent margins and spacing
- Clear information hierarchy
- Visual elements support but don't overwhelm text
- Balance between text and imagery
## Language
- Use the same language as the content provided below
- Match punctuation style to the content language
---
Please use nano banana pro to generate the slide based on the content provided below:
+331
View File
@@ -0,0 +1,331 @@
---
name: xhs-images
description: Xiaohongshu (Little Red Book) infographic series generator with multiple style options. Breaks down content into 1-10 cartoon-style infographics. Use when user asks to create "小红书图片", "XHS images", or "RedNote infographics".
---
# Xiaohongshu Infographic Series Generator
Break down complex content into eye-catching infographic series for Xiaohongshu with multiple style options.
## Usage
```bash
# Auto-select style based on content
/xhs-images posts/ai-future/article.md
# Specify a style
/xhs-images posts/ai-future/article.md --style cute
/xhs-images posts/ai-future/article.md --style tech
/xhs-images posts/ai-future/article.md --style minimal
# Direct content input
/xhs-images
[paste content]
# Direct input with style
/xhs-images --style bold
[paste content]
```
## Options
| Option | Description |
|--------|-------------|
| `--style <name>` | Specify infographic style (see Style Gallery below) |
## Style Gallery
### 1. `cute` (Default)
Sweet, adorable, girly - classic Xiaohongshu aesthetic
- **Colors**: Pink, peach, mint, lavender, cream background
- **Elements**: Cute stickers, emoji icons, hearts, stars, sparkles
- **Best for**: Lifestyle, beauty, fashion, daily tips
### 2. `fresh`
Clean, refreshing, natural
- **Colors**: Mint green, sky blue, white, light yellow
- **Elements**: Plant icons, clouds, simple shapes, breathing room
- **Best for**: Health, wellness, minimalist lifestyle, self-care
### 3. `tech`
Modern, smart, digital
- **Colors**: Deep blue, purple, cyan, dark backgrounds with neon accents
- **Elements**: Geometric shapes, data icons, circuit patterns, glowing effects
- **Best for**: Tech tutorials, AI content, digital tools, productivity
### 4. `warm`
Cozy, friendly, approachable
- **Colors**: Warm orange, golden yellow, brown, cream
- **Elements**: Sun motifs, coffee cups, cozy illustrations, warm lighting
- **Best for**: Personal stories, life lessons, emotional content
### 5. `bold`
High impact, attention-grabbing
- **Colors**: Red, orange, black, yellow accents
- **Elements**: Strong typography, exclamation marks, arrows, contrast
- **Best for**: Important tips, warnings, must-know content
### 6. `minimal`
Ultra-clean, sophisticated
- **Colors**: Black, white, single accent color
- **Elements**: Maximum whitespace, simple icons, clean lines
- **Best for**: Professional content, serious topics, elegant presentations
### 7. `retro`
Vintage, nostalgic, trendy
- **Colors**: Muted pastels, sepia, faded tones
- **Elements**: Vintage badges, halftone dots, classic typography
- **Best for**: Throwback content, classic tips, timeless advice
### 8. `pop`
Vibrant, energetic, eye-catching
- **Colors**: Bright primary colors, neon accents, white
- **Elements**: Bold shapes, comic-style elements, dynamic compositions
- **Best for**: Exciting announcements, fun facts, engaging tutorials
## Auto Style Selection
When no `--style` is specified, analyze content to select:
| Content Signals | Selected Style |
|----------------|----------------|
| Beauty, fashion, cute, girl, pink | `cute` |
| Health, nature, clean, fresh, organic | `fresh` |
| Tech, AI, code, digital, app, tool | `tech` |
| Life, story, emotion, feeling, warm | `warm` |
| Warning, important, must, critical | `bold` |
| Professional, business, elegant, simple | `minimal` |
| Classic, vintage, old, traditional | `retro` |
| Fun, exciting, wow, amazing | `pop` |
## File Management
### With Article Path
Save to `xhs-images/` subdirectory in the same folder as the article:
```
posts/ai-future/
├── article.md
└── xhs-images/
├── outline.md
├── prompts/
│ ├── 01-cover.md
│ ├── 02-content-1.md
│ └── ...
├── 01-cover.png
├── 02-content-1.png
└── 03-ending.png
```
### Without Article Path
Save to `xhs-outputs/YYYY-MM-DD/[topic-slug]/`:
```
xhs-outputs/
└── 2026-01-13/
└── ai-agent-guide/
├── outline.md
├── prompts/
│ ├── 01-cover.md
│ └── ...
├── 01-cover.png
└── 02-ending.png
```
## Workflow
### Step 1: Analyze Content & Select Style
1. Read content
2. If `--style` specified, use that style
3. Otherwise, scan for style signals and auto-select
4. Determine image count based on content complexity:
| Content Type | Image Count |
|-------------|-------------|
| Simple opinion / single topic | 2-3 |
| Medium complexity / tutorial | 4-6 |
| Deep dive / multi-dimensional | 7-10 |
### Step 2: Generate Outline
Plan for each image with style-specific approach:
```markdown
# Xiaohongshu Infographic Series Outline
**Topic**: [topic description]
**Style**: [selected style]
**Image Count**: N
**Generated**: YYYY-MM-DD HH:mm
---
## Image 1 of N
**Position**: Cover
**Core Message**: [one-liner]
**Filename**: 01-cover.png
**Text Content**:
- Title: xxx
- Subtitle: xxx
**Visual Concept**: [style-appropriate description]
---
## Image 2 of N
...
```
### Step 3: Save Outline
Save outline as `outline.md`.
### Step 4: Generate Images One by One
For each image, create a style-specific prompt file.
**Prompt Format**:
```markdown
Infographic theme: [topic]
Style: [style name]
Position: [cover/content/ending]
Visual composition:
- Main visual: [style-appropriate description]
- Layout: [element positioning for 3:4 vertical format]
- Decorative elements: [style-specific decorations]
Color scheme:
- Primary: [style primary color]
- Background: [style background color]
- Accent: [style accent color]
Text content:
- Title: 「xxx」(large, prominent)
- Key points: 「xxx」「xxx」
Style notes: [specific style characteristics]
```
Then generate using:
```bash
/gemini-web --promptfiles [SKILL_ROOT]/skills/xhs-images/prompts/system.md [TARGET_DIR]/prompts/01-cover.md --image [TARGET_DIR]/01-cover.png
```
After each image:
1. Confirm generation success
2. Report progress: "Generated X/N"
3. Continue to next
### Step 5: Completion Report
```
Xiaohongshu Infographic Series Complete!
Topic: [topic]
Style: [style name]
Location: [directory path]
Images: N total
- 01-cover.png ✓ Cover
- 02-content-1.png ✓ Content
- 03-content-2.png ✓ Content
- 04-ending.png ✓ Ending
Outline: outline.md
```
## Style Reference Details
### cute
```
Colors: Pink (#FED7E2), peach (#FEEBC8), mint (#C6F6D5), lavender (#E9D8FD)
Background: Cream (#FFFAF0), soft pink (#FFF5F7)
Accents: Hot pink, coral
Elements: Hearts, stars, sparkles, cute faces, ribbon decorations, sticker-style
Typography: Rounded, bubbly hand lettering
```
### fresh
```
Colors: Mint green (#9AE6B4), sky blue (#90CDF4), light yellow (#FAF089)
Background: Pure white (#FFFFFF), soft mint (#F0FFF4)
Accents: Leaf green, water blue
Elements: Plant leaves, clouds, water drops, simple geometric shapes
Typography: Clean, light hand lettering with breathing room
```
### tech
```
Colors: Deep blue (#1A365D), purple (#6B46C1), cyan (#00D4FF)
Background: Dark gray (#1A202C), near-black (#0D1117)
Accents: Neon green (#00FF88), electric blue
Elements: Circuit patterns, data icons, geometric grids, glowing effects
Typography: Monospace-style hand lettering, subtle glow
```
### warm
```
Colors: Warm orange (#ED8936), golden yellow (#F6AD55), terracotta (#C05621)
Background: Cream (#FFFAF0), soft peach (#FED7AA)
Accents: Deep brown (#744210), soft red
Elements: Sun rays, coffee cups, cozy items, warm lighting effects
Typography: Friendly, rounded hand lettering
```
### bold
```
Colors: Vibrant red (#E53E3E), orange (#DD6B20), yellow (#F6E05E)
Background: Deep black (#000000), dark charcoal
Accents: White, neon yellow
Elements: Exclamation marks, arrows, warning icons, strong shapes
Typography: Bold, impactful hand lettering with shadows
```
### minimal
```
Colors: Black (#000000), white (#FFFFFF)
Background: Off-white (#FAFAFA), pure white
Accents: Single color (content-derived - blue, green, or coral)
Elements: Single focal point, thin lines, maximum whitespace
Typography: Clean, simple hand lettering
```
### retro
```
Colors: Muted orange, dusty pink (#FED7E2 at 70%), faded teal
Background: Aged paper (#F5E6D3), sepia tones
Accents: Faded red, vintage gold
Elements: Halftone dots, vintage badges, classic icons, tape effects
Typography: Vintage-style hand lettering, classic feel
```
### pop
```
Colors: Bright red (#F56565), yellow (#ECC94B), blue (#4299E1), green (#48BB78)
Background: White (#FFFFFF), light gray
Accents: Neon pink, electric purple
Elements: Bold shapes, speech bubbles, comic-style effects, starburst
Typography: Dynamic, energetic hand lettering with outlines
```
## Content Breakdown Principles
1. **Cover (Image 1)**: Strong visual impact, core title, attention hook
2. **Content (Middle)**: One core point per image, moderate information density
3. **Ending (Last)**: Summary / call-to-action / memorable quote
## Notes
- Image generation typically takes 10-30 seconds per image
- Auto-retry once on generation failure
- Use cartoon alternatives for sensitive public figures
- Output language matches input content language
- Maintain selected style consistency across all images in series
+33
View File
@@ -0,0 +1,33 @@
Create a Xiaohongshu (Little Red Book) style infographic following these guidelines:
## Image Specifications
- **Type**: Infographic
- **Orientation**: Portrait (vertical)
- **Aspect Ratio**: 3:4
- **Style**: Hand-drawn illustration
## Core Principles
- Hand-drawn quality throughout - NO realistic or photographic elements
- If content involves sensitive or copyrighted figures, create stylistically similar alternatives - DO NOT refuse to generate
- Keep information concise, highlight keywords and core concepts
- Use ample whitespace for easy visual scanning
- Maintain clear visual hierarchy
## Text Style (CRITICAL)
- **ALL text MUST be hand-drawn style**
- Main titles should be prominent and eye-catching
- Key text should be bold and enlarged
- Use highlighter effects to emphasize keywords
- **DO NOT use realistic or computer-generated fonts**
## Language
- Use the same language as the content provided below
- Match punctuation style to the content language (Chinese: "",。!)
---
Please use nano banana pro to generate the infographic based on the content provided below: