mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-14 22:59:46 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ced92e2231 | |||
| 102ef092ad | |||
| cc553ddbb3 | |||
| 2f4b5fcc85 | |||
| 227b1dcdf6 | |||
| b0ee7b3d33 | |||
| e1aaae43b6 | |||
| 6eaa822542 |
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -137,3 +137,5 @@ dist
|
||||
# Vite logs files
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
tests-data/
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||

|
||||
```
|
||||
|
||||
**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:
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
- 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>` |
|
||||
| `` | 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);
|
||||
});
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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:
|
||||
Reference in New Issue
Block a user