mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-10 05:01:40 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da920bb830 | |||
| f49acb9983 | |||
| 3fddcc0e6a | |||
| d4070b19d5 | |||
| 6430c67efe | |||
| 73a5f60ad3 | |||
| 7a2f475417 | |||
| bb494f07ba | |||
| ced92e2231 | |||
| 102ef092ad | |||
| cc553ddbb3 | |||
| 2f4b5fcc85 | |||
| 227b1dcdf6 | |||
| b0ee7b3d33 | |||
| e1aaae43b6 | |||
| 6eaa822542 |
@@ -1,20 +1,28 @@
|
||||
{
|
||||
"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.4.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/baoyu-gemini-web",
|
||||
"./skills/baoyu-xhs-images",
|
||||
"./skills/baoyu-post-to-x",
|
||||
"./skills/baoyu-post-to-wechat",
|
||||
"./skills/baoyu-article-illustrator",
|
||||
"./skills/baoyu-cover-image",
|
||||
"./skills/baoyu-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,6 @@ dist
|
||||
# Vite logs files
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
tests-data/
|
||||
|
||||
.DS_Store
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Claude Code marketplace plugin providing AI-powered content generation skills. Skills use Gemini Web API (reverse-engineered) for text/image generation and Chrome CDP for browser automation.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
skills/
|
||||
├── baoyu-gemini-web/ # Core: Gemini API wrapper (text + image gen)
|
||||
├── baoyu-xhs-images/ # Xiaohongshu infographic series (1-10 images)
|
||||
├── baoyu-cover-image/ # Article cover images (2.35:1 aspect)
|
||||
├── baoyu-slide-deck/ # Presentation slides with outlines
|
||||
├── baoyu-article-illustrator/ # Smart illustration placement
|
||||
├── baoyu-post-to-x/ # X/Twitter posting automation
|
||||
└── baoyu-post-to-wechat/ # WeChat Official Account posting
|
||||
```
|
||||
|
||||
Each skill contains:
|
||||
- `SKILL.md` - YAML front matter (name, description) + documentation
|
||||
- `scripts/` - TypeScript implementations
|
||||
- `prompts/system.md` - AI generation guidelines (optional)
|
||||
|
||||
## Running Skills
|
||||
|
||||
All scripts run via Bun (no build step):
|
||||
|
||||
```bash
|
||||
npx -y bun skills/<skill>/scripts/main.ts [options]
|
||||
```
|
||||
|
||||
Examples:
|
||||
```bash
|
||||
# Text generation
|
||||
npx -y bun skills/baoyu-gemini-web/scripts/main.ts "Hello"
|
||||
|
||||
# Image generation
|
||||
npx -y bun skills/baoyu-gemini-web/scripts/main.ts --prompt "A cat" --image cat.png
|
||||
|
||||
# From prompt files
|
||||
npx -y bun skills/baoyu-gemini-web/scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||
```
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
- **Bun**: TypeScript runtime (via `npx -y bun`)
|
||||
- **Chrome**: Required for `baoyu-gemini-web` auth and `baoyu-post-to-x` automation
|
||||
- **No npm packages**: Self-contained TypeScript, no external dependencies
|
||||
|
||||
## Authentication
|
||||
|
||||
`baoyu-gemini-web` uses browser cookies for Google auth:
|
||||
- First run opens Chrome for login
|
||||
- Cookies cached in data directory
|
||||
- Force refresh: `--login` flag
|
||||
|
||||
## Plugin Configuration
|
||||
|
||||
`.claude-plugin/marketplace.json` defines plugin metadata and skill paths. Version follows semver.
|
||||
|
||||
## Adding New Skills
|
||||
|
||||
**IMPORTANT**: All skills MUST use `baoyu-` prefix to avoid conflicts when users import this plugin.
|
||||
|
||||
1. Create `skills/baoyu-<name>/SKILL.md` with YAML front matter
|
||||
- Directory name: `baoyu-<name>`
|
||||
- SKILL.md `name` field: `baoyu-<name>`
|
||||
2. Add TypeScript in `skills/baoyu-<name>/scripts/`
|
||||
3. Add prompt templates in `skills/baoyu-<name>/prompts/` if needed
|
||||
4. Register in `marketplace.json` plugins[0].skills array as `./skills/baoyu-<name>`
|
||||
|
||||
## Code Style
|
||||
|
||||
- TypeScript throughout, no comments
|
||||
- Async/await patterns
|
||||
- Short variable names
|
||||
- Type-safe interfaces
|
||||
@@ -0,0 +1,165 @@
|
||||
# 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 with **Style × Layout** two-dimensional system.
|
||||
|
||||
```bash
|
||||
# Auto-select style and layout
|
||||
/xhs-images posts/ai-future/article.md
|
||||
|
||||
# Specify style
|
||||
/xhs-images posts/ai-future/article.md --style notion
|
||||
|
||||
# Specify layout
|
||||
/xhs-images posts/ai-future/article.md --layout dense
|
||||
|
||||
# Combine style and layout
|
||||
/xhs-images posts/ai-future/article.md --style tech --layout list
|
||||
|
||||
# Direct content input
|
||||
/xhs-images 今日星座运势
|
||||
```
|
||||
|
||||
**Styles** (visual aesthetics): `cute` (default), `fresh`, `tech`, `warm`, `bold`, `minimal`, `retro`, `pop`, `notion`
|
||||
|
||||
**Layouts** (information density):
|
||||
| Layout | Density | Best for |
|
||||
|--------|---------|----------|
|
||||
| `sparse` | 1-2 pts | Covers, quotes |
|
||||
| `balanced` | 3-4 pts | Regular content |
|
||||
| `dense` | 5-8 pts | Knowledge cards, cheat sheets |
|
||||
| `list` | 4-7 items | Checklists, rankings |
|
||||
| `comparison` | 2 sides | Before/after, pros/cons |
|
||||
| `flow` | 3-6 steps | Processes, timelines |
|
||||
|
||||
### 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 images from content. Creates comprehensive outlines with style instructions, then generates individual slide images.
|
||||
|
||||
```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
|
||||
|
||||
# Outline only (no image generation)
|
||||
/slide-deck path/to/article.md --outline-only
|
||||
|
||||
# With language
|
||||
/slide-deck path/to/article.md --lang zh
|
||||
```
|
||||
|
||||
Available styles: `editorial` (default), `corporate`, `technical`, `playful`, `minimal`, `storytelling`, `warm`, `retro-flat`, `notion`
|
||||
|
||||
### post-to-wechat
|
||||
|
||||
Post content to WeChat Official Account (微信公众号). Two modes available:
|
||||
|
||||
**Image-Text (图文)** - Multiple images with short title/content:
|
||||
|
||||
```bash
|
||||
/post-to-wechat 图文 --markdown article.md --images ./photos/
|
||||
/post-to-wechat 图文 --markdown article.md --image img1.png --image img2.png --image img3.png
|
||||
/post-to-wechat 图文 --title "标题" --content "内容" --image img1.png --submit
|
||||
```
|
||||
|
||||
**Article (文章)** - Full markdown/HTML with rich formatting:
|
||||
|
||||
```bash
|
||||
/post-to-wechat 文章 --markdown article.md
|
||||
/post-to-wechat 文章 --markdown article.md --theme grace
|
||||
/post-to-wechat 文章 --html article.html
|
||||
```
|
||||
|
||||
Prerequisites: Google Chrome installed. First run requires QR code login (session preserved).
|
||||
|
||||
## 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
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
# 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 posts/ai-future/article.md --style notion
|
||||
|
||||
# 指定布局
|
||||
/xhs-images posts/ai-future/article.md --layout dense
|
||||
|
||||
# 组合风格和布局
|
||||
/xhs-images posts/ai-future/article.md --style tech --layout list
|
||||
|
||||
# 直接输入内容
|
||||
/xhs-images 今日星座运势
|
||||
```
|
||||
|
||||
**风格**(视觉美学):`cute`(默认)、`fresh`、`tech`、`warm`、`bold`、`minimal`、`retro`、`pop`、`notion`
|
||||
|
||||
**布局**(信息密度):
|
||||
| 布局 | 密度 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| `sparse` | 1-2 点 | 封面、金句 |
|
||||
| `balanced` | 3-4 点 | 常规内容 |
|
||||
| `dense` | 5-8 点 | 知识卡片、干货总结 |
|
||||
| `list` | 4-7 项 | 清单、排行 |
|
||||
| `comparison` | 双栏 | 对比、优劣 |
|
||||
| `flow` | 3-6 步 | 流程、时间线 |
|
||||
|
||||
### 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 --outline-only
|
||||
|
||||
# 指定语言
|
||||
/slide-deck path/to/article.md --lang zh
|
||||
```
|
||||
|
||||
可用风格:`editorial`(默认)、`corporate`、`technical`、`playful`、`minimal`、`storytelling`、`warm`、`retro-flat`、`notion`
|
||||
|
||||
### post-to-wechat
|
||||
|
||||
发布内容到微信公众号,支持两种模式:
|
||||
|
||||
**图文模式** - 多图配短标题和正文:
|
||||
|
||||
```bash
|
||||
/post-to-wechat 图文 --markdown article.md --images ./photos/
|
||||
/post-to-wechat 图文 --markdown article.md --image img1.png --image img2.png --image img3.png
|
||||
/post-to-wechat 图文 --title "标题" --content "内容" --image img1.png --submit
|
||||
```
|
||||
|
||||
**文章模式** - 完整 markdown/HTML 富文本格式:
|
||||
|
||||
```bash
|
||||
/post-to-wechat 文章 --markdown article.md
|
||||
/post-to-wechat 文章 --markdown article.md --theme grace
|
||||
/post-to-wechat 文章 --html article.html
|
||||
```
|
||||
|
||||
前置要求:已安装 Google Chrome,首次运行需扫码登录(登录状态会保存)
|
||||
|
||||
## 免责声明
|
||||
|
||||
### gemini-web
|
||||
|
||||
此技能使用 Gemini Web API(逆向工程)。
|
||||
|
||||
**警告:** 本项目通过浏览器 cookies 使用非官方 API。使用风险自负。
|
||||
|
||||
- 首次运行会打开 Chrome 进行 Google 身份验证
|
||||
- Cookies 会被缓存供后续使用
|
||||
- 不保证 API 的稳定性或可用性
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,328 @@
|
||||
---
|
||||
name: baoyu-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
|
||||
/baoyu-article-illustrator path/to/article.md
|
||||
|
||||
# Specify a style
|
||||
/baoyu-article-illustrator path/to/article.md --style tech
|
||||
/baoyu-article-illustrator path/to/article.md --style warm
|
||||
/baoyu-article-illustrator path/to/article.md --style minimal
|
||||
|
||||
# Combine with other options
|
||||
/baoyu-article-illustrator path/to/article.md --style playful
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--style <name>` | Specify illustration style (see Style Gallery below) |
|
||||
|
||||
## Style Gallery
|
||||
|
||||
### 1. `elegant`
|
||||
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
|
||||
|
||||
### 9. `notion` (Default)
|
||||
Minimalist hand-drawn line art, intellectual
|
||||
- **Colors**: Black outlines, white background, 1-2 pastel accents
|
||||
- **Elements**: Simple line doodles, geometric shapes, hand-drawn wobble, maximum whitespace
|
||||
- **Best for**: Knowledge sharing, concept explanations, SaaS content, productivity articles
|
||||
|
||||
## 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` |
|
||||
| Knowledge, concept, productivity, SaaS, notion | `notion` |
|
||||
|
||||
## 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
|
||||
|
||||
**Image Generation Skill Selection**:
|
||||
1. Check available image generation skills
|
||||
2. If multiple skills available, ask user to choose
|
||||
|
||||
**Generation Flow**:
|
||||
1. Call selected image generation skill with prompt file and output path
|
||||
2. Generate images sequentially
|
||||
3. After each image, output progress: "Generated X/N"
|
||||
4. On failure, auto-retry once
|
||||
5. 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
|
||||
```
|
||||
|
||||
### notion
|
||||
```
|
||||
Colors: Black (#1A1A1A), dark gray (#4A4A4A)
|
||||
Background: Pure white (#FFFFFF), off-white (#FAFAFA)
|
||||
Accents: Pastel blue (#A8D4F0), pastel yellow (#F9E79F), pastel pink (#FADBD8)
|
||||
Elements: Simple line doodles, hand-drawn wobble effect, geometric shapes, stick figures, maximum whitespace
|
||||
Typography: Clean hand-drawn lettering, simple sans-serif labels
|
||||
```
|
||||
|
||||
## 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,295 @@
|
||||
---
|
||||
name: baoyu-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)
|
||||
/baoyu-cover-image path/to/article.md
|
||||
|
||||
# Specify a style
|
||||
/baoyu-cover-image path/to/article.md --style tech
|
||||
/baoyu-cover-image path/to/article.md --style warm
|
||||
/baoyu-cover-image path/to/article.md --style bold
|
||||
|
||||
# Without title text
|
||||
/baoyu-cover-image path/to/article.md --no-title
|
||||
|
||||
# Combine options
|
||||
/baoyu-cover-image path/to/article.md --style minimal --no-title
|
||||
|
||||
# From direct text input
|
||||
/baoyu-cover-image
|
||||
[paste content or describe the topic]
|
||||
|
||||
# Direct input with style
|
||||
/baoyu-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
|
||||
|
||||
**Image Generation Skill Selection**:
|
||||
1. Check available image generation skills
|
||||
2. If multiple skills available, ask user to choose
|
||||
|
||||
**Generation**:
|
||||
Call selected image generation skill with prompt file and 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:
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: gemini-web
|
||||
name: baoyu-gemini-web
|
||||
description: Interacts with Gemini Web to generate text and images. Use when the user needs AI-generated content via Gemini, including text responses and image generation.
|
||||
---
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -100,7 +100,7 @@ export async function loadGeminiCookies(
|
||||
}
|
||||
|
||||
log?.(
|
||||
'[gemini-web] Missing Gemini auth cookies. Run `npx -y bun skills/gemini-web/scripts/main.ts --login` to sign in and refresh cookies.',
|
||||
'[gemini-web] Missing Gemini auth cookies. Run `npx -y bun skills/baoyu-gemini-web/scripts/main.ts --login` to sign in and refresh cookies.',
|
||||
);
|
||||
return merged;
|
||||
}
|
||||
@@ -124,7 +124,7 @@ export function createGeminiWebExecutor(
|
||||
const cookieMap = await loadGeminiCookies(runOptions.config, log);
|
||||
if (!hasRequiredGeminiCookies(cookieMap)) {
|
||||
throw new Error(
|
||||
'Gemini browser mode requires auth cookies (missing __Secure-1PSID/__Secure-1PSIDTS). Run `npx -y bun skills/gemini-web/scripts/main.ts --login` to sign in and save cookies.',
|
||||
'Gemini browser mode requires auth cookies (missing __Secure-1PSID/__Secure-1PSIDTS). Run `npx -y bun skills/baoyu-gemini-web/scripts/main.ts --login` to sign in and save cookies.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,12 +16,14 @@ function printUsage(exitCode = 0): never {
|
||||
const profileDir = resolveGeminiWebChromeProfileDir();
|
||||
|
||||
console.log(`Usage:
|
||||
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/baoyu-gemini-web/scripts/main.ts --prompt "Hello"
|
||||
npx -y bun skills/baoyu-gemini-web/scripts/main.ts "Hello"
|
||||
npx -y bun skills/baoyu-gemini-web/scripts/main.ts --prompt "A cute cat" --image generated.png
|
||||
npx -y bun skills/baoyu-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,54 @@
|
||||
---
|
||||
name: baoyu-post-to-wechat
|
||||
description: Post content to WeChat Official Account (微信公众号). Supports both article posting (文章) and image-text posting (图文).
|
||||
---
|
||||
|
||||
# Post to WeChat Official Account (微信公众号)
|
||||
|
||||
Post content to WeChat Official Account using Chrome CDP automation.
|
||||
|
||||
## Quick Usage
|
||||
|
||||
### Image-Text (图文) - Multiple images with title/content
|
||||
|
||||
```bash
|
||||
# From markdown file and image directory
|
||||
npx -y bun ./scripts/wechat-browser.ts --markdown article.md --images ./images/
|
||||
|
||||
# With explicit parameters
|
||||
npx -y bun ./scripts/wechat-browser.ts --title "标题" --content "内容" --image img1.png --image img2.png --submit
|
||||
```
|
||||
|
||||
### Article (文章) - Full markdown with formatting
|
||||
|
||||
```bash
|
||||
# Post markdown article
|
||||
npx -y bun ./scripts/wechat-article.ts --markdown article.md --theme grace
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- **Image-Text Posting**: See `references/image-text-posting.md` for detailed image-text posting guide
|
||||
- **Article Posting**: See `references/article-posting.md` for detailed article posting guide
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Google Chrome installed
|
||||
- `bun` runtime (via `npx -y bun`)
|
||||
- First run: log in to WeChat Official Account in the opened browser window
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | Image-Text | Article |
|
||||
|---------|------------|---------|
|
||||
| Multiple images | ✓ (up to 9) | ✓ (inline) |
|
||||
| Markdown support | Title/content extraction | Full formatting |
|
||||
| Auto title compression | ✓ (to 20 chars) | ✗ |
|
||||
| Content compression | ✓ (to 1000 chars) | ✗ |
|
||||
| Themes | ✗ | ✓ (default, grace, simple) |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Not logged in**: First run opens browser - scan QR code to log in, session is preserved
|
||||
- **Chrome not found**: Set `WECHAT_BROWSER_CHROME_PATH` environment variable
|
||||
- **Paste fails**: Check system clipboard permissions
|
||||
@@ -0,0 +1,89 @@
|
||||
# Article Posting (文章发表)
|
||||
|
||||
Post markdown articles to WeChat Official Account with full formatting support.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Post markdown article
|
||||
npx -y bun ./scripts/wechat-article.ts --markdown article.md
|
||||
|
||||
# With theme
|
||||
npx -y bun ./scripts/wechat-article.ts --markdown article.md --theme grace
|
||||
|
||||
# With explicit options
|
||||
npx -y bun ./scripts/wechat-article.ts --markdown article.md --author "作者名" --summary "摘要"
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `--markdown <path>` | Markdown file to convert and post |
|
||||
| `--theme <name>` | Theme: default, grace, or simple |
|
||||
| `--title <text>` | Override title (auto-extracted from markdown) |
|
||||
| `--author <name>` | Author name (default: 宝玉) |
|
||||
| `--summary <text>` | Article summary |
|
||||
| `--html <path>` | Pre-rendered HTML file (alternative to markdown) |
|
||||
| `--profile <dir>` | Chrome profile directory |
|
||||
|
||||
## Markdown Format
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Article Title
|
||||
author: Author Name
|
||||
---
|
||||
|
||||
# Title (becomes article title)
|
||||
|
||||
Regular paragraph with **bold** and *italic*.
|
||||
|
||||
## Section Header
|
||||
|
||||

|
||||
|
||||
- List item 1
|
||||
- List item 2
|
||||
|
||||
> Blockquote text
|
||||
|
||||
[Link text](https://example.com)
|
||||
```
|
||||
|
||||
## Image Handling
|
||||
|
||||
1. **Parse**: Images in markdown are replaced with `[[IMAGE_PLACEHOLDER_N]]`
|
||||
2. **Render**: HTML is generated with placeholders in text
|
||||
3. **Paste**: HTML content is pasted into WeChat editor
|
||||
4. **Replace**: For each placeholder:
|
||||
- Find and select the placeholder text
|
||||
- Scroll into view
|
||||
- Press Backspace to delete the placeholder
|
||||
- Paste the image from clipboard
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `wechat-article.ts` | Main article publishing script |
|
||||
| `md-to-wechat.ts` | Markdown to HTML with placeholders |
|
||||
| `md/render.ts` | Markdown rendering with themes |
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
User: /post-to-wechat --markdown ./article.md
|
||||
|
||||
Claude:
|
||||
1. Parses markdown, finds 5 images
|
||||
2. Generates HTML with placeholders
|
||||
3. Opens Chrome, navigates to WeChat editor
|
||||
4. Pastes HTML content
|
||||
5. For each image:
|
||||
- Selects [[IMAGE_PLACEHOLDER_1]]
|
||||
- Scrolls into view
|
||||
- Presses Backspace to delete
|
||||
- Pastes image
|
||||
6. Reports: "Article composed with 5 images."
|
||||
```
|
||||
@@ -0,0 +1,92 @@
|
||||
# Image-Text Posting (图文发表)
|
||||
|
||||
Post image-text messages with multiple images to WeChat Official Account.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Post with images and markdown file (title/content extracted automatically)
|
||||
npx -y bun ./scripts/wechat-browser.ts --markdown source.md --images ./images/
|
||||
|
||||
# Post with explicit title and content
|
||||
npx -y bun ./scripts/wechat-browser.ts --title "标题" --content "内容" --image img1.png --image img2.png
|
||||
|
||||
# Save as draft
|
||||
npx -y bun ./scripts/wechat-browser.ts --markdown source.md --images ./images/ --submit
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `--markdown <path>` | Markdown file for title/content extraction |
|
||||
| `--images <dir>` | Directory containing images (sorted by name) |
|
||||
| `--title <text>` | Article title (max 20 chars, auto-compressed if too long) |
|
||||
| `--content <text>` | Article content (max 1000 chars, auto-compressed if too long) |
|
||||
| `--image <path>` | Single image file (can be repeated) |
|
||||
| `--submit` | Save as draft (default: preview only) |
|
||||
| `--profile <dir>` | Chrome profile directory |
|
||||
|
||||
## Auto Title/Content from Markdown
|
||||
|
||||
When using `--markdown`, the script:
|
||||
|
||||
1. **Parses frontmatter** for title and author:
|
||||
```yaml
|
||||
---
|
||||
title: 文章标题
|
||||
author: 作者名
|
||||
---
|
||||
```
|
||||
|
||||
2. **Falls back to H1** if no frontmatter title:
|
||||
```markdown
|
||||
# 这将成为标题
|
||||
```
|
||||
|
||||
3. **Compresses title** to 20 characters if too long:
|
||||
- Original: "如何在一天内彻底重塑你的人生"
|
||||
- Compressed: "一天彻底重塑你的人生"
|
||||
|
||||
4. **Extracts first paragraphs** as content (max 1000 chars)
|
||||
|
||||
## Image Directory Mode
|
||||
|
||||
When using `--images <dir>`:
|
||||
|
||||
- All PNG/JPG files in directory are uploaded
|
||||
- Files are sorted alphabetically by name
|
||||
- Naming convention: `01-cover.png`, `02-content.png`, etc.
|
||||
|
||||
## Constraints
|
||||
|
||||
| Field | Max Length | Notes |
|
||||
|-------|------------|-------|
|
||||
| Title | 20 chars | Auto-compressed if longer |
|
||||
| Content | 1000 chars | Auto-compressed if longer |
|
||||
| Images | 9 max | WeChat limit |
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
User: /post-to-wechat --markdown ./article.md --images ./xhs-images/
|
||||
|
||||
Claude:
|
||||
1. Parses markdown meta:
|
||||
- Title: "如何在一天内彻底重塑你的人生" → "一天内重塑你的人生"
|
||||
- Author: from frontmatter or default
|
||||
2. Extracts content from first paragraphs
|
||||
3. Finds 7 images in xhs-images/
|
||||
4. Opens Chrome, navigates to WeChat "图文" editor
|
||||
5. Uploads all images
|
||||
6. Fills title and content
|
||||
7. Reports: "Image-text posted with 7 images."
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `wechat-browser.ts` | Main image-text posting script |
|
||||
| `cdp.ts` | Chrome DevTools Protocol utilities |
|
||||
| `copy-to-clipboard.ts` | Clipboard operations |
|
||||
@@ -0,0 +1,274 @@
|
||||
import { spawn } 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';
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function findChromeExecutable(): string | undefined {
|
||||
const override = process.env.WECHAT_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');
|
||||
break;
|
||||
}
|
||||
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getDefaultProfileDir(): string {
|
||||
const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
||||
return path.join(base, 'wechat-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)}`);
|
||||
}
|
||||
|
||||
export 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 {}
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChromeSession {
|
||||
cdp: CdpConnection;
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
}
|
||||
|
||||
export async function launchChrome(url: string, profileDir?: string): Promise<{ cdp: CdpConnection; chrome: ReturnType<typeof spawn> }> {
|
||||
const chromePath = findChromeExecutable();
|
||||
if (!chromePath) throw new Error('Chrome not found. Set WECHAT_BROWSER_CHROME_PATH env var.');
|
||||
|
||||
const profile = profileDir ?? getDefaultProfileDir();
|
||||
await mkdir(profile, { recursive: true });
|
||||
|
||||
const port = await getFreePort();
|
||||
console.log(`[cdp] Launching Chrome (profile: ${profile})`);
|
||||
|
||||
const chrome = spawn(chromePath, [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profile}`,
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--start-maximized',
|
||||
url,
|
||||
], { stdio: 'ignore' });
|
||||
|
||||
const wsUrl = await waitForChromeDebugPort(port, 30_000);
|
||||
const cdp = await CdpConnection.connect(wsUrl, 30_000);
|
||||
|
||||
return { cdp, chrome };
|
||||
}
|
||||
|
||||
export async function getPageSession(cdp: CdpConnection, urlPattern: string): Promise<ChromeSession> {
|
||||
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(urlPattern));
|
||||
|
||||
if (!pageTarget) throw new Error(`Page not found: ${urlPattern}`);
|
||||
|
||||
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 });
|
||||
|
||||
return { cdp, sessionId, targetId: pageTarget.targetId };
|
||||
}
|
||||
|
||||
export async function waitForNewTab(cdp: CdpConnection, initialIds: Set<string>, urlPattern: string, timeoutMs = 30_000): Promise<string> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
||||
const newTab = targets.targetInfos.find(t => t.type === 'page' && !initialIds.has(t.targetId) && t.url.includes(urlPattern));
|
||||
if (newTab) return newTab.targetId;
|
||||
await sleep(500);
|
||||
}
|
||||
throw new Error(`New tab not found: ${urlPattern}`);
|
||||
}
|
||||
|
||||
export async function clickElement(session: ChromeSession, selector: string): Promise<void> {
|
||||
const posResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
(function() {
|
||||
const el = document.querySelector('${selector}');
|
||||
if (!el) return 'null';
|
||||
el.scrollIntoView({ block: 'center' });
|
||||
const rect = el.getBoundingClientRect();
|
||||
return JSON.stringify({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 });
|
||||
})()
|
||||
`,
|
||||
returnByValue: true,
|
||||
}, { sessionId: session.sessionId });
|
||||
|
||||
if (posResult.result.value === 'null') throw new Error(`Element not found: ${selector}`);
|
||||
const pos = JSON.parse(posResult.result.value);
|
||||
|
||||
await session.cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });
|
||||
await sleep(50);
|
||||
await session.cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });
|
||||
}
|
||||
|
||||
export async function typeText(session: ChromeSession, text: string): Promise<void> {
|
||||
const lines = text.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].length > 0) {
|
||||
await session.cdp.send('Input.insertText', { text: lines[i] }, { sessionId: session.sessionId });
|
||||
}
|
||||
if (i < lines.length - 1) {
|
||||
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId: session.sessionId });
|
||||
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId: session.sessionId });
|
||||
}
|
||||
await sleep(30);
|
||||
}
|
||||
}
|
||||
|
||||
export async function pasteFromClipboard(session: ChromeSession): Promise<void> {
|
||||
const modifiers = process.platform === 'darwin' ? 4 : 2;
|
||||
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId });
|
||||
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId });
|
||||
}
|
||||
|
||||
export async function evaluate<T = unknown>(session: ChromeSession, expression: string): Promise<T> {
|
||||
const result = await session.cdp.send<{ result: { value: T } }>('Runtime.evaluate', {
|
||||
expression,
|
||||
returnByValue: true,
|
||||
}, { sessionId: session.sessionId });
|
||||
return result.result.value;
|
||||
}
|
||||
@@ -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,281 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import { createHash } from 'node:crypto';
|
||||
import https from 'node:https';
|
||||
import http from 'node:http';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import process from 'node:process';
|
||||
|
||||
interface ImageInfo {
|
||||
placeholder: string;
|
||||
localPath: string;
|
||||
originalPath: string;
|
||||
}
|
||||
|
||||
interface ParsedResult {
|
||||
title: string;
|
||||
author: string;
|
||||
summary: string;
|
||||
htmlPath: string;
|
||||
contentImages: ImageInfo[];
|
||||
}
|
||||
|
||||
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-wechat] Downloading: ${imagePath}`);
|
||||
await downloadFile(imagePath, localPath);
|
||||
}
|
||||
return localPath;
|
||||
}
|
||||
|
||||
if (path.isAbsolute(imagePath)) {
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
return path.resolve(baseDir, imagePath);
|
||||
}
|
||||
|
||||
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]! };
|
||||
}
|
||||
|
||||
async function parseMarkdownForWechat(
|
||||
markdownPath: string,
|
||||
options?: { title?: string; theme?: string; tempDir?: string },
|
||||
): Promise<ParsedResult> {
|
||||
const content = fs.readFileSync(markdownPath, 'utf-8');
|
||||
const baseDir = path.dirname(markdownPath);
|
||||
const tempDir = options?.tempDir ?? path.join(os.tmpdir(), 'wechat-article-images');
|
||||
const theme = options?.theme ?? 'default';
|
||||
|
||||
await mkdir(tempDir, { recursive: true });
|
||||
|
||||
const { frontmatter, body } = parseFrontmatter(content);
|
||||
|
||||
let title = options?.title ?? frontmatter.title ?? '';
|
||||
if (!title) {
|
||||
const h1Match = body.match(/^#\s+(.+)$/m);
|
||||
if (h1Match) title = h1Match[1]!;
|
||||
}
|
||||
|
||||
const author = frontmatter.author ?? '';
|
||||
let summary = frontmatter.summary ?? frontmatter.description ?? '';
|
||||
|
||||
if (!summary) {
|
||||
const lines = body.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (trimmed.startsWith('#')) continue;
|
||||
if (trimmed.startsWith('![')) continue;
|
||||
if (trimmed.startsWith('>')) continue;
|
||||
if (trimmed.startsWith('-') || trimmed.startsWith('*')) continue;
|
||||
if (/^\d+\./.test(trimmed)) continue;
|
||||
|
||||
const cleanText = trimmed
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||
.replace(/\*(.+?)\*/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/`([^`]+)`/g, '$1');
|
||||
|
||||
if (cleanText.length > 20) {
|
||||
summary = cleanText.length > 120 ? cleanText.slice(0, 117) + '...' : cleanText;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const images: Array<{ src: string; placeholder: string }> = [];
|
||||
let imageCounter = 0;
|
||||
|
||||
const modifiedBody = body.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
|
||||
const placeholder = `[[IMAGE_PLACEHOLDER_${++imageCounter}]]`;
|
||||
images.push({ src, placeholder });
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
const modifiedMarkdown = `---\n${Object.entries(frontmatter).map(([k, v]) => `${k}: ${v}`).join('\n')}\n---\n${modifiedBody}`;
|
||||
|
||||
const tempMdPath = path.join(tempDir, 'temp-article.md');
|
||||
await writeFile(tempMdPath, modifiedMarkdown, 'utf-8');
|
||||
|
||||
const scriptDir = path.dirname(new URL(import.meta.url).pathname);
|
||||
const renderScript = path.join(scriptDir, 'md', 'render.ts');
|
||||
|
||||
console.error(`[md-to-wechat] Rendering markdown with theme: ${theme}`);
|
||||
const result = spawnSync('npx', ['-y', 'bun', renderScript, tempMdPath, '--theme', theme], {
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
cwd: baseDir,
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr?.toString() || '';
|
||||
throw new Error(`Render failed: ${stderr}`);
|
||||
}
|
||||
|
||||
const htmlPath = tempMdPath.replace(/\.md$/i, '.html');
|
||||
if (!fs.existsSync(htmlPath)) {
|
||||
throw new Error(`HTML file not generated: ${htmlPath}`);
|
||||
}
|
||||
|
||||
const contentImages: ImageInfo[] = [];
|
||||
for (const img of images) {
|
||||
const localPath = await resolveImagePath(img.src, baseDir, tempDir);
|
||||
contentImages.push({
|
||||
placeholder: img.placeholder,
|
||||
localPath,
|
||||
originalPath: img.src,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
author,
|
||||
summary,
|
||||
htmlPath,
|
||||
contentImages,
|
||||
};
|
||||
}
|
||||
|
||||
function printUsage(): never {
|
||||
console.log(`Convert Markdown to WeChat-ready HTML with image placeholders
|
||||
|
||||
Usage:
|
||||
npx -y bun md-to-wechat.ts <markdown_file> [options]
|
||||
|
||||
Options:
|
||||
--title <title> Override title
|
||||
--theme <name> Theme name (default, grace, simple)
|
||||
--help Show this help
|
||||
|
||||
Output JSON format:
|
||||
{
|
||||
"title": "Article Title",
|
||||
"htmlPath": "/tmp/wechat-article-images/temp-article.html",
|
||||
"contentImages": [
|
||||
{
|
||||
"placeholder": "[[IMAGE_PLACEHOLDER_1]]",
|
||||
"localPath": "/tmp/wechat-article-images/img.png",
|
||||
"originalPath": "imgs/image.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Example:
|
||||
npx -y bun md-to-wechat.ts article.md
|
||||
npx -y bun md-to-wechat.ts article.md --theme grace
|
||||
`);
|
||||
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 theme: 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 === '--theme' && args[i + 1]) {
|
||||
theme = 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 parseMarkdownForWechat(markdownPath, { title, theme });
|
||||
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,18 @@
|
||||
This directory contains code adapted from the doocs/md project.
|
||||
|
||||
Original project: https://github.com/doocs/md
|
||||
License: WTFPL (Do What The Fuck You Want To Public License)
|
||||
|
||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||
Version 2, December 2004
|
||||
|
||||
Copyright (C) 2025 Doocs <admin@doocs.org>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim or modified
|
||||
copies of this license document, and changing it is allowed as long
|
||||
as the name is changed.
|
||||
|
||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. You just DO WHAT THE FUCK YOU WANT TO.
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { MarkedExtension, Tokens } from 'marked'
|
||||
|
||||
export interface AlertOptions {
|
||||
className?: string
|
||||
variants?: AlertVariantItem[]
|
||||
withoutStyle?: boolean
|
||||
}
|
||||
|
||||
export interface AlertVariantItem {
|
||||
type: string
|
||||
icon: string
|
||||
title?: string
|
||||
titleClassName?: string
|
||||
}
|
||||
|
||||
function ucfirst(str: string) {
|
||||
return str.slice(0, 1).toUpperCase() + str.slice(1).toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* https://github.com/bent10/marked-extensions/tree/main/packages/alert
|
||||
* To support theme, we need to modify the source code.
|
||||
* A [marked](https://marked.js.org/) extension to support [GFM alerts](https://github.com/orgs/community/discussions/16925).
|
||||
*/
|
||||
export function markedAlert(options: AlertOptions = {}): MarkedExtension {
|
||||
const { className = `markdown-alert`, variants = [], withoutStyle = false } = options
|
||||
const resolvedVariants = resolveVariants(variants)
|
||||
|
||||
// 提取公共的元数据构建逻辑
|
||||
function buildMeta(variantType: string, matchedVariant: AlertVariantItem, fromContainer = false) {
|
||||
return {
|
||||
className,
|
||||
variant: variantType,
|
||||
icon: matchedVariant.icon,
|
||||
title: matchedVariant.title ?? ucfirst(variantType),
|
||||
titleClassName: `${className}-title`,
|
||||
fromContainer,
|
||||
}
|
||||
}
|
||||
|
||||
// 提取公共的渲染逻辑
|
||||
function renderAlert(token: any) {
|
||||
const { meta, tokens = [] } = token
|
||||
// @ts-expect-error marked renderer context has parser property
|
||||
const text = this.parser.parse(tokens)
|
||||
// 新主题系统:使用 CSS 选择器而非内联样式
|
||||
let tmpl = `<blockquote class="${meta.className} ${meta.className}-${meta.variant}">\n`
|
||||
tmpl += `<p class="${meta.titleClassName} alert-title-${meta.variant}">`
|
||||
if (!withoutStyle) {
|
||||
// 给 SVG 添加 class,通过 CSS 控制颜色
|
||||
tmpl += meta.icon.replace(
|
||||
`<svg`,
|
||||
`<svg class="alert-icon-${meta.variant}"`,
|
||||
)
|
||||
}
|
||||
tmpl += meta.title
|
||||
tmpl += `</p>\n`
|
||||
tmpl += text
|
||||
tmpl += `</blockquote>\n`
|
||||
|
||||
return tmpl
|
||||
}
|
||||
|
||||
return {
|
||||
walkTokens(token) {
|
||||
if (token.type !== `blockquote`)
|
||||
return
|
||||
|
||||
const matchedVariant = resolvedVariants.find(({ type }) =>
|
||||
new RegExp(createSyntaxPattern(type), `i`).test(token.text),
|
||||
)
|
||||
|
||||
if (matchedVariant) {
|
||||
const { type: variantType } = matchedVariant
|
||||
const typeRegexp = new RegExp(createSyntaxPattern(variantType), `i`)
|
||||
|
||||
Object.assign(token, {
|
||||
type: `alert`,
|
||||
meta: buildMeta(variantType, matchedVariant),
|
||||
})
|
||||
|
||||
const firstLine = token.tokens?.[0] as Tokens.Paragraph
|
||||
const firstLineText = firstLine.raw?.replace(typeRegexp, ``).trim()
|
||||
|
||||
if (firstLineText) {
|
||||
const patternToken = firstLine.tokens[0] as Tokens.Text
|
||||
|
||||
Object.assign(patternToken, {
|
||||
raw: patternToken.raw.replace(typeRegexp, ``),
|
||||
text: patternToken.text.replace(typeRegexp, ``),
|
||||
})
|
||||
|
||||
if (firstLine.tokens[1]?.type === `br`) {
|
||||
firstLine.tokens.splice(1, 1)
|
||||
}
|
||||
}
|
||||
else {
|
||||
token.tokens?.shift()
|
||||
}
|
||||
}
|
||||
},
|
||||
extensions: [
|
||||
{
|
||||
name: `alert`,
|
||||
level: `block`,
|
||||
renderer: renderAlert,
|
||||
},
|
||||
{
|
||||
name: `alertContainer`,
|
||||
level: `block`,
|
||||
start(src) {
|
||||
return src.match(/^:::/)?.index
|
||||
},
|
||||
tokenizer(src, _tokens) {
|
||||
// eslint-disable-next-line regexp/no-super-linear-backtracking
|
||||
const match = /^:::\s*(\w+)\s*\n([\s\S]*?)\n:::/.exec(src)
|
||||
|
||||
if (match) {
|
||||
const [raw, variant, content] = match
|
||||
const matchedVariant = resolvedVariants.find(v => v.type === variant)
|
||||
if (!matchedVariant)
|
||||
return
|
||||
|
||||
return {
|
||||
type: `alert`,
|
||||
raw,
|
||||
text: content.trim(),
|
||||
tokens: this.lexer.blockTokens(content.trim()),
|
||||
meta: buildMeta(variant, matchedVariant, true),
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer: renderAlert,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The default configuration for alert variants.
|
||||
*/
|
||||
const defaultAlertVariant: AlertVariantItem[] = [
|
||||
{
|
||||
type: `note`,
|
||||
icon: `<svg class="octicon octicon-info" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`,
|
||||
},
|
||||
{
|
||||
type: `info`,
|
||||
icon: `<svg class="octicon octicon-info" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`,
|
||||
},
|
||||
{
|
||||
type: `tip`,
|
||||
icon: `<svg class="octicon octicon-light-bulb" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"></path></svg>`,
|
||||
},
|
||||
{
|
||||
type: `important`,
|
||||
icon: `<svg class="octicon octicon-report" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
|
||||
},
|
||||
{
|
||||
type: `warning`,
|
||||
icon: `<svg class="octicon octicon-alert" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
|
||||
},
|
||||
{
|
||||
type: `caution`,
|
||||
icon: `<svg class="octicon octicon-stop" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`,
|
||||
},
|
||||
// Obsidian-style callouts
|
||||
{
|
||||
type: `abstract`,
|
||||
title: `Abstract`,
|
||||
icon: `<svg class="octicon octicon-clipboard" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 1a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.5a.75.75 0 0 0-.75-.75Zm4.5-1.5a2.25 2.25 0 0 1 2.122 1.5H13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h.628A2.25 2.25 0 0 1 5.75-.5ZM3.5 3v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5Z"></path></svg>`,
|
||||
},
|
||||
{
|
||||
type: `summary`,
|
||||
title: `Summary`,
|
||||
icon: `<svg class="octicon octicon-clipboard" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 1a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.5a.75.75 0 0 0-.75-.75Zm4.5-1.5a2.25 2.25 0 0 1 2.122 1.5H13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h.628A2.25 2.25 0 0 1 5.75-.5ZM3.5 3v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5Z"></path></svg>`,
|
||||
},
|
||||
{
|
||||
type: `tldr`,
|
||||
title: `TL;DR`,
|
||||
icon: `<svg class="octicon octicon-clipboard" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 1a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.5a.75.75 0 0 0-.75-.75Zm4.5-1.5a2.25 2.25 0 0 1 2.122 1.5H13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h.628A2.25 2.25 0 0 1 5.75-.5ZM3.5 3v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5Z"></path></svg>`,
|
||||
},
|
||||
{
|
||||
type: `todo`,
|
||||
title: `Todo`,
|
||||
icon: `<svg class="octicon octicon-checklist" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.5 1.75v11.5c0 .138.112.25.25.25h3.17a.75.75 0 0 1 0 1.5H2.75A1.75 1.75 0 0 1 1 13.25V1.75C1 .784 1.784 0 2.75 0h8.5C12.216 0 13 .784 13 1.75v7.736a.75.75 0 0 1-1.5 0V1.75a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm10.97 8.72a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1-1.06 1.06l-1.22-1.22v4.94a.75.75 0 0 1-1.5 0v-4.94l-1.22 1.22a.75.75 0 0 1-1.06-1.06Z"></path></svg>`,
|
||||
},
|
||||
{
|
||||
type: `success`,
|
||||
title: `Success`,
|
||||
icon: `<svg class="octicon octicon-check-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0Z"></path></svg>`,
|
||||
},
|
||||
{
|
||||
type: `done`,
|
||||
title: `Done`,
|
||||
icon: `<svg class="octicon octicon-check-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0Z"></path></svg>`,
|
||||
},
|
||||
{
|
||||
type: `question`,
|
||||
title: `Question`,
|
||||
icon: `<svg class="octicon octicon-question" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
|
||||
},
|
||||
{
|
||||
type: `help`,
|
||||
title: `Help`,
|
||||
icon: `<svg class="octicon octicon-question" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
|
||||
},
|
||||
{
|
||||
type: `faq`,
|
||||
title: `FAQ`,
|
||||
icon: `<svg class="octicon octicon-question" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
|
||||
},
|
||||
{
|
||||
type: `failure`,
|
||||
title: `Failure`,
|
||||
icon: `<svg class="octicon octicon-x-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z"></path></svg>`,
|
||||
},
|
||||
{
|
||||
type: `fail`,
|
||||
title: `Fail`,
|
||||
icon: `<svg class="octicon octicon-x-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z"></path></svg>`,
|
||||
},
|
||||
{
|
||||
type: `missing`,
|
||||
title: `Missing`,
|
||||
icon: `<svg class="octicon octicon-x-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z"></path></svg>`,
|
||||
},
|
||||
{
|
||||
type: `danger`,
|
||||
title: `Danger`,
|
||||
icon: `<svg class="octicon octicon-zap" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M9.504.43a1.516 1.516 0 0 1 2.437 1.713L10.415 5.5h2.123c1.57 0 2.346 1.909 1.22 3.004l-7.34 7.142a1.249 1.249 0 0 1-.871.354h-.302a1.25 1.25 0 0 1-1.157-1.723L5.633 10.5H3.462c-1.57 0-2.346-1.909-1.22-3.004ZM9.98 1.873a.016.016 0 0 0-.016.006L2.252 9.021a.75.75 0 0 0 .488 1.212h3.838a.75.75 0 0 1 .694 1.034L5.545 15.02a.016.016 0 0 0 .003.017.017.017 0 0 0 .018.004h.302a.016.016 0 0 0 .012-.005l7.34-7.142a.75.75 0 0 0-.488-1.212h-3.838a.75.75 0 0 1-.694-1.034l1.527-3.628a.016.016 0 0 0-.003-.017.017.017 0 0 0-.018-.004h-.302Z"></path></svg>`,
|
||||
},
|
||||
{
|
||||
type: `error`,
|
||||
title: `Error`,
|
||||
icon: `<svg class="octicon octicon-zap" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M9.504.43a1.516 1.516 0 0 1 2.437 1.713L10.415 5.5h2.123c1.57 0 2.346 1.909 1.22 3.004l-7.34 7.142a1.249 1.249 0 0 1-.871.354h-.302a1.25 1.25 0 0 1-1.157-1.723L5.633 10.5H3.462c-1.57 0-2.346-1.909-1.22-3.004ZM9.98 1.873a.016.016 0 0 0-.016.006L2.252 9.021a.75.75 0 0 0 .488 1.212h3.838a.75.75 0 0 1 .694 1.034L5.545 15.02a.016.016 0 0 0 .003.017.017.017 0 0 0 .018.004h.302a.016.016 0 0 0 .012-.005l7.34-7.142a.75.75 0 0 0-.488-1.212h-3.838a.75.75 0 0 1-.694-1.034l1.527-3.628a.016.016 0 0 0-.003-.017.017.017 0 0 0-.018-.004h-.302Z"></path></svg>`,
|
||||
},
|
||||
{
|
||||
type: `bug`,
|
||||
title: `Bug`,
|
||||
icon: `<svg class="octicon octicon-bug" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4.72.22a.75.75 0 0 1 1.06 0l1 .999a3.488 3.488 0 0 1 2.441 0l.999-1a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.775.776c.616.63.995 1.493.995 2.444v.327c0 .1-.009.197-.025.292l.727.726a.75.75 0 1 1-1.06 1.06l-.727-.727a2.17 2.17 0 0 1-.292.026H9.25V7.5a.75.75 0 0 1-1.5 0V6.125H6.875a2.17 2.17 0 0 1-.292-.026l-.727.727a.75.75 0 1 1-1.06-1.06l.727-.726a2.17 2.17 0 0 1-.025-.292V4.5c0-.951.379-1.814.995-2.444l-.775-.776a.75.75 0 0 1 0-1.06Zm6.437 6.003A.608.608 0 0 0 11 6.072v-.026a3.999 3.999 0 0 0-.11-.936 2.488 2.488 0 0 0-5.78 0 3.992 3.992 0 0 0-.11.936v.026c0 .05.008.098.02.147h4.937a.612.612 0 0 0 .2-.02ZM2.25 7.5a.75.75 0 0 0 0 1.5h.5v1.25a4.75 4.75 0 0 0 2.478 4.166l.247.137a.75.75 0 1 0 .722-1.313l-.246-.137A3.25 3.25 0 0 1 4.25 10.25V9h7.5v1.25a3.25 3.25 0 0 1-1.701 2.853l-.246.137a.75.75 0 1 0 .722 1.313l.247-.137A4.75 4.75 0 0 0 13.25 10.25V9h.5a.75.75 0 0 0 0-1.5Z"></path></svg>`,
|
||||
},
|
||||
{
|
||||
type: `example`,
|
||||
title: `Example`,
|
||||
icon: `<svg class="octicon octicon-list-unordered" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 2.5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5ZM2 14a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-6a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM2 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`,
|
||||
},
|
||||
{
|
||||
type: `quote`,
|
||||
title: `Quote`,
|
||||
icon: `<svg class="octicon octicon-quote" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25v-8.5C0 2.784.784 2 1.75 2ZM1.5 12.25c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25ZM4 5.25a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5A.75.75 0 0 1 4 5.25Zm0 4a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1-.75-.75Z"></path></svg>`,
|
||||
},
|
||||
{
|
||||
type: `cite`,
|
||||
title: `Cite`,
|
||||
icon: `<svg class="octicon octicon-quote" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25v-8.5C0 2.784.784 2 1.75 2ZM1.5 12.25c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25ZM4 5.25a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5A.75.75 0 0 1 4 5.25Zm0 4a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1-.75-.75Z"></path></svg>`,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Resolves the variants configuration, combining the provided variants with
|
||||
* the default variants.
|
||||
*/
|
||||
export function resolveVariants(variants: AlertVariantItem[]) {
|
||||
if (!variants.length)
|
||||
return defaultAlertVariant
|
||||
|
||||
return Object.values(
|
||||
[...defaultAlertVariant, ...variants].reduce(
|
||||
(map, item) => {
|
||||
map[item.type] = item
|
||||
return map
|
||||
},
|
||||
{} as { [key: string]: AlertVariantItem },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns regex pattern to match alert syntax.
|
||||
*/
|
||||
export function createSyntaxPattern(type: string) {
|
||||
return `^(?:\\[!${type}])\\s*?\n*`
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { MarkedExtension, Tokens } from 'marked'
|
||||
/**
|
||||
* A marked extension to support footnotes syntax.
|
||||
* Syntax:
|
||||
* This is a footnote reference[^1][^2].
|
||||
*
|
||||
* [^1]: .....
|
||||
* [^2]: .....
|
||||
*/
|
||||
|
||||
interface MapContent {
|
||||
index: number
|
||||
text: string
|
||||
}
|
||||
const fnMap = new Map<string, MapContent>()
|
||||
|
||||
export function markedFootnotes(): MarkedExtension {
|
||||
return {
|
||||
extensions: [
|
||||
{
|
||||
name: `footnoteDef`,
|
||||
level: `block`,
|
||||
start(src: string) {
|
||||
fnMap.clear()
|
||||
return src.match(/^\[\^/)?.index
|
||||
},
|
||||
tokenizer(src: string) {
|
||||
const match = src.match(/^\[\^(.*)\]:(.*)/)
|
||||
if (match) {
|
||||
const [raw, fnId, text] = match
|
||||
const index = fnMap.size + 1
|
||||
fnMap.set(fnId, { index, text })
|
||||
return {
|
||||
type: `footnoteDef`,
|
||||
raw,
|
||||
fnId,
|
||||
index,
|
||||
text,
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
renderer(token: Tokens.Generic) {
|
||||
const { index, text, fnId } = token
|
||||
const fnInner = `
|
||||
<code>${index}.</code>
|
||||
<span>${text}</span>
|
||||
<a id="fnDef-${fnId}" href="#fnRef-${fnId}" style="color: var(--md-primary-color);">\u21A9\uFE0E</a>
|
||||
<br>`
|
||||
if (index === 1) {
|
||||
return `
|
||||
<p style="font-size: 80%;margin: 0.5em 8px;word-break:break-all;">${fnInner}`
|
||||
}
|
||||
if (index === fnMap.size) {
|
||||
return `${fnInner}</p>`
|
||||
}
|
||||
return fnInner
|
||||
},
|
||||
},
|
||||
{
|
||||
name: `footnoteRef`,
|
||||
level: `inline`,
|
||||
start(src: string) {
|
||||
return src.match(/\[\^/)?.index
|
||||
},
|
||||
tokenizer(src: string) {
|
||||
const match = src.match(/^\[\^(.*?)\]/)
|
||||
if (match) {
|
||||
const [raw, fnId] = match
|
||||
if (fnMap.has(fnId)) {
|
||||
return {
|
||||
type: `footnoteRef`,
|
||||
raw,
|
||||
fnId,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer(token: Tokens.Generic) {
|
||||
const { fnId } = token
|
||||
const { index } = fnMap.get(fnId) as MapContent
|
||||
return `<sup style="color: var(--md-primary-color);">
|
||||
<a href="#fnDef-${fnId}" id="fnRef-${fnId}">\[${index}\]</a>
|
||||
</sup>`
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Markdown 扩展导出
|
||||
export * from './alert.js'
|
||||
export * from './footnotes.js'
|
||||
export * from './infographic.js'
|
||||
export * from './katex.js'
|
||||
export * from './markup.js'
|
||||
export * from './plantuml.js'
|
||||
export * from './ruby.js'
|
||||
export * from './slider.js'
|
||||
export * from './toc.js'
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { MarkedExtension } from 'marked'
|
||||
|
||||
interface InfographicOptions {
|
||||
themeMode?: 'dark' | 'light'
|
||||
}
|
||||
|
||||
async function renderInfographic(containerId: string, code: string, options?: InfographicOptions) {
|
||||
if (typeof window === 'undefined')
|
||||
return
|
||||
|
||||
try {
|
||||
const { Infographic, setDefaultFont, setFontExtendFactor, exportToSVG } = await import('@antv/infographic')
|
||||
|
||||
setFontExtendFactor(1.1)
|
||||
setDefaultFont('-apple-system-font, "system-ui", "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif')
|
||||
|
||||
const findContainer = (retries = 5, delay = 100) => {
|
||||
const container = document.getElementById(containerId)
|
||||
if (container) {
|
||||
const isDark = options?.themeMode === 'dark'
|
||||
|
||||
// 从 CSS 变量中读取主题颜色
|
||||
const root = document.documentElement
|
||||
const computedStyle = getComputedStyle(root)
|
||||
const primaryColor = computedStyle.getPropertyValue('--md-primary-color').trim()
|
||||
const backgroundColor = computedStyle.getPropertyValue('--background').trim()
|
||||
|
||||
// 转换 HSL 格式
|
||||
const toHSLString = (variant: string) => {
|
||||
const vars = variant.split(' ')
|
||||
if (vars.length === 3)
|
||||
return `hsl(${vars.join(', ')})`
|
||||
if (vars.length === 4)
|
||||
return `hsla(${vars.join(', ')})`
|
||||
return ''
|
||||
}
|
||||
|
||||
const instance = new Infographic({
|
||||
container,
|
||||
svg: {
|
||||
style: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: isDark ? '#000' : 'transparent',
|
||||
},
|
||||
background: false,
|
||||
},
|
||||
theme: isDark ? 'dark' : 'default',
|
||||
themeConfig: {
|
||||
colorPrimary: primaryColor || undefined,
|
||||
colorBg: toHSLString(backgroundColor) || undefined,
|
||||
},
|
||||
})
|
||||
|
||||
instance.on('loaded', ({ node }) => {
|
||||
exportToSVG(node, { removeIds: true }).then((svg) => {
|
||||
container.replaceChildren(svg)
|
||||
})
|
||||
})
|
||||
|
||||
instance.render(code)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (retries > 0) {
|
||||
setTimeout(() => findContainer(retries - 1, delay), delay)
|
||||
}
|
||||
}
|
||||
|
||||
findContainer()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to render Infographic:', error)
|
||||
const container = document.getElementById(containerId)
|
||||
if (container) {
|
||||
container.innerHTML = `<div style="color: red; padding: 10px; border: 1px solid red;">Infographic 渲染失败: ${error instanceof Error ? error.message : String(error)}</div>`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function markedInfographic(options?: InfographicOptions): MarkedExtension {
|
||||
const className = 'infographic-diagram'
|
||||
|
||||
return {
|
||||
extensions: [
|
||||
{
|
||||
name: 'infographic',
|
||||
level: 'block',
|
||||
start(src: string) {
|
||||
return src.match(/^```infographic/m)?.index
|
||||
},
|
||||
tokenizer(src: string) {
|
||||
const match = /^```infographic\r?\n([\s\S]*?)\r?\n```/.exec(src)
|
||||
if (match) {
|
||||
return {
|
||||
type: 'infographic',
|
||||
raw: match[0],
|
||||
text: match[1].trim(),
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer(token: any) {
|
||||
const id = `infographic-${Math.random().toString(36).slice(2, 11)}`
|
||||
const code = token.text
|
||||
|
||||
renderInfographic(id, code, options)
|
||||
|
||||
return `<div id="${id}" class="${className}" style="width: 100%;">正在加载 Infographic...</div>`
|
||||
},
|
||||
},
|
||||
],
|
||||
walkTokens(token: any) {
|
||||
if (token.type === 'code' && token.lang === 'infographic') {
|
||||
token.type = 'infographic'
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { MarkedExtension } from 'marked'
|
||||
|
||||
export interface MarkedKatexOptions {
|
||||
nonStandard?: boolean
|
||||
}
|
||||
|
||||
const inlineRule = /^(\${1,2})(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n$]))\1(?=[\s?!.,:?!。,:]|$)/
|
||||
const inlineRuleNonStandard = /^(\${1,2})(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n$]))\1/ // Non-standard, even if there are no spaces before and after $ or $$, try to parse
|
||||
|
||||
const blockRule = /^\s{0,3}(\${1,2})[ \t]*\n([\s\S]+?)\n\s{0,3}\1[ \t]*(?:\n|$)/
|
||||
|
||||
// LaTeX style rules for \( ... \) and \[ ... \]
|
||||
const inlineLatexRule = /^\\\(([^\\]*(?:\\.[^\\]*)*?)\\\)/
|
||||
const blockLatexRule = /^\\\[([^\\]*(?:\\.[^\\]*)*?)\\\]/
|
||||
|
||||
function createRenderer(display: boolean, withStyle: boolean = true) {
|
||||
return (token: any) => {
|
||||
// @ts-expect-error MathJax is a global variable
|
||||
window.MathJax.texReset()
|
||||
// @ts-expect-error MathJax is a global variable
|
||||
const mjxContainer = window.MathJax.tex2svg(token.text, { display })
|
||||
const svg = mjxContainer.firstChild
|
||||
const width = svg.style[`min-width`] || svg.getAttribute(`width`)
|
||||
svg.removeAttribute(`width`)
|
||||
|
||||
// 行内公式对齐 https://groups.google.com/g/mathjax-users/c/zThKffrrCvE?pli=1
|
||||
// 直接覆盖 style 会覆盖 MathJax 的样式,需要手动设置
|
||||
// svg.style = `max-width: 300vw !important; display: initial; flex-shrink: 0;`
|
||||
|
||||
if (withStyle) {
|
||||
svg.style.display = `initial`
|
||||
svg.style.setProperty(`max-width`, `300vw`, `important`)
|
||||
svg.style.flexShrink = `0`
|
||||
svg.style.width = width
|
||||
}
|
||||
|
||||
if (!display) {
|
||||
// 新主题系统:使用 class 而非内联样式
|
||||
return `<span class="katex-inline">${svg.outerHTML}</span>`
|
||||
}
|
||||
|
||||
return `<section class="katex-block">${svg.outerHTML}</section>`
|
||||
}
|
||||
}
|
||||
|
||||
function inlineKatex(options: MarkedKatexOptions | undefined, renderer: any) {
|
||||
const nonStandard = options && options.nonStandard
|
||||
const ruleReg = nonStandard ? inlineRuleNonStandard : inlineRule
|
||||
return {
|
||||
name: `inlineKatex`,
|
||||
level: `inline`,
|
||||
start(src: string) {
|
||||
let index
|
||||
let indexSrc = src
|
||||
|
||||
while (indexSrc) {
|
||||
index = indexSrc.indexOf(`$`)
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
const f = nonStandard ? index > -1 : index === 0 || indexSrc.charAt(index - 1) === ` `
|
||||
if (f) {
|
||||
const possibleKatex = indexSrc.substring(index)
|
||||
|
||||
if (possibleKatex.match(ruleReg)) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
indexSrc = indexSrc.substring(index + 1).replace(/^\$+/, ``)
|
||||
}
|
||||
},
|
||||
tokenizer(src: string) {
|
||||
const match = src.match(ruleReg)
|
||||
if (match) {
|
||||
return {
|
||||
type: `inlineKatex`,
|
||||
raw: match[0],
|
||||
text: match[2].trim(),
|
||||
displayMode: match[1].length === 2,
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer,
|
||||
}
|
||||
}
|
||||
|
||||
function blockKatex(_options: MarkedKatexOptions | undefined, renderer: any) {
|
||||
return {
|
||||
name: `blockKatex`,
|
||||
level: `block`,
|
||||
tokenizer(src: string) {
|
||||
const match = src.match(blockRule)
|
||||
if (match) {
|
||||
return {
|
||||
type: `blockKatex`,
|
||||
raw: match[0],
|
||||
text: match[2].trim(),
|
||||
displayMode: match[1].length === 2,
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer,
|
||||
}
|
||||
}
|
||||
|
||||
function inlineLatexKatex(_options: MarkedKatexOptions | undefined, renderer: any) {
|
||||
return {
|
||||
name: `inlineLatexKatex`,
|
||||
level: `inline`,
|
||||
start(src: string) {
|
||||
const index = src.indexOf(`\\(`)
|
||||
return index !== -1 ? index : undefined
|
||||
},
|
||||
tokenizer(src: string) {
|
||||
const match = src.match(inlineLatexRule)
|
||||
if (match) {
|
||||
return {
|
||||
type: `inlineLatexKatex`,
|
||||
raw: match[0],
|
||||
text: match[1].trim(),
|
||||
displayMode: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer,
|
||||
}
|
||||
}
|
||||
|
||||
function blockLatexKatex(_options: MarkedKatexOptions | undefined, renderer: any) {
|
||||
return {
|
||||
name: `blockLatexKatex`,
|
||||
level: `block`,
|
||||
start(src: string) {
|
||||
const index = src.indexOf(`\\[`)
|
||||
return index !== -1 ? index : undefined
|
||||
},
|
||||
tokenizer(src: string) {
|
||||
const match = src.match(blockLatexRule)
|
||||
if (match) {
|
||||
return {
|
||||
type: `blockLatexKatex`,
|
||||
raw: match[0],
|
||||
text: match[1].trim(),
|
||||
displayMode: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer,
|
||||
}
|
||||
}
|
||||
|
||||
export function MDKatex(options: MarkedKatexOptions | undefined, withStyle: boolean = true): MarkedExtension {
|
||||
return {
|
||||
extensions: [
|
||||
inlineKatex(options, createRenderer(false, withStyle)),
|
||||
blockKatex(options, createRenderer(true, withStyle)),
|
||||
inlineLatexKatex(options, createRenderer(false, withStyle)),
|
||||
blockLatexKatex(options, createRenderer(true, withStyle)),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { MarkedExtension } from 'marked'
|
||||
|
||||
/**
|
||||
* 扩展标记语法:
|
||||
* - 高亮: ==文本==
|
||||
* - 下划线: ++文本++
|
||||
* - 波浪线: ~文本~
|
||||
*/
|
||||
export function markedMarkup(): MarkedExtension {
|
||||
return {
|
||||
extensions: [
|
||||
// 高亮语法 ==文本==
|
||||
{
|
||||
name: `markup_highlight`,
|
||||
level: `inline`,
|
||||
start(src: string) {
|
||||
return src.match(/==(?!=)/)?.index
|
||||
},
|
||||
tokenizer(src: string) {
|
||||
const rule = /^==((?:[^=]|=(?!=))+)==/
|
||||
const match = rule.exec(src)
|
||||
if (match) {
|
||||
return {
|
||||
type: `markup_highlight`,
|
||||
raw: match[0],
|
||||
text: match[1],
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer(token: any) {
|
||||
// 新主题系统:使用 class 而非内联样式
|
||||
return `<span class="markup-highlight">${token.text}</span>`
|
||||
},
|
||||
},
|
||||
|
||||
// 下划线语法 ++文本++
|
||||
{
|
||||
name: `markup_underline`,
|
||||
level: `inline`,
|
||||
start(src: string) {
|
||||
return src.match(/\+\+(?!\+)/)?.index
|
||||
},
|
||||
tokenizer(src: string) {
|
||||
const rule = /^\+\+((?:[^+]|\+(?!\+))+)\+\+/
|
||||
const match = rule.exec(src)
|
||||
if (match) {
|
||||
return {
|
||||
type: `markup_underline`,
|
||||
raw: match[0],
|
||||
text: match[1],
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer(token: any) {
|
||||
// 新主题系统:使用 class 而非内联样式
|
||||
return `<span class="markup-underline">${token.text}</span>`
|
||||
},
|
||||
},
|
||||
|
||||
// 波浪线语法 ~文本~
|
||||
{
|
||||
name: `markup_wavyline`,
|
||||
level: `inline`,
|
||||
start(src: string) {
|
||||
// 查找单个 ~ 但不是连续的 ~~
|
||||
return src.match(/~(?!~)/)?.index
|
||||
},
|
||||
tokenizer(src: string) {
|
||||
// 匹配 ~文本~ 但确保不是 ~~文本~~
|
||||
const rule = /^~([^~\n]+)~(?!~)/
|
||||
const match = rule.exec(src)
|
||||
if (match) {
|
||||
return {
|
||||
type: `markup_wavyline`,
|
||||
raw: match[0],
|
||||
text: match[1],
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer(token: any) {
|
||||
// 新主题系统:使用 class 而非内联样式
|
||||
return `<span class="markup-wavyline">${token.text}</span>`
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import type { MarkedExtension, Tokens } from 'marked'
|
||||
import { deflateSync } from 'fflate'
|
||||
|
||||
export interface PlantUMLOptions {
|
||||
/**
|
||||
* PlantUML 服务器地址
|
||||
* @default 'https://www.plantuml.com/plantuml'
|
||||
*/
|
||||
serverUrl?: string
|
||||
/**
|
||||
* 渲染格式
|
||||
* @default 'svg'
|
||||
*/
|
||||
format?: `svg` | `png`
|
||||
/**
|
||||
* CSS 类名
|
||||
* @default 'plantuml-diagram'
|
||||
*/
|
||||
className?: string
|
||||
/**
|
||||
* 是否内嵌SVG内容(用于微信公众号等不支持外链图片的环境)
|
||||
* @default false
|
||||
*/
|
||||
inlineSvg?: boolean
|
||||
/**
|
||||
* 自定义样式
|
||||
*/
|
||||
styles?: {
|
||||
container?: Record<string, string | number>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PlantUML 专用的 6-bit 编码函数
|
||||
* 基于官方文档 https://plantuml.com/text-encoding
|
||||
*/
|
||||
function encode6bit(b: number): string {
|
||||
if (b < 10) {
|
||||
return String.fromCharCode(48 + b)
|
||||
}
|
||||
b -= 10
|
||||
if (b < 26) {
|
||||
return String.fromCharCode(65 + b)
|
||||
}
|
||||
b -= 26
|
||||
if (b < 26) {
|
||||
return String.fromCharCode(97 + b)
|
||||
}
|
||||
b -= 26
|
||||
if (b === 0) {
|
||||
return `-`
|
||||
}
|
||||
if (b === 1) {
|
||||
return `_`
|
||||
}
|
||||
return `?`
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 3 个字节附加到编码字符串中
|
||||
* 基于官方文档 https://plantuml.com/text-encoding
|
||||
*/
|
||||
function append3bytes(b1: number, b2: number, b3: number): string {
|
||||
const c1 = b1 >> 2
|
||||
const c2 = ((b1 & 0x3) << 4) | (b2 >> 4)
|
||||
const c3 = ((b2 & 0xF) << 2) | (b3 >> 6)
|
||||
const c4 = b3 & 0x3F
|
||||
let r = ``
|
||||
r += encode6bit(c1 & 0x3F)
|
||||
r += encode6bit(c2 & 0x3F)
|
||||
r += encode6bit(c3 & 0x3F)
|
||||
r += encode6bit(c4 & 0x3F)
|
||||
return r
|
||||
}
|
||||
|
||||
/**
|
||||
* PlantUML 专用的 base64 编码函数
|
||||
* 基于官方文档 https://plantuml.com/text-encoding
|
||||
*/
|
||||
function encode64(data: string): string {
|
||||
let r = ``
|
||||
for (let i = 0; i < data.length; i += 3) {
|
||||
if (i + 2 === data.length) {
|
||||
r += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), 0)
|
||||
}
|
||||
else if (i + 1 === data.length) {
|
||||
r += append3bytes(data.charCodeAt(i), 0, 0)
|
||||
}
|
||||
else {
|
||||
r += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), data.charCodeAt(i + 2))
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 fflate 库进行 Deflate 压缩
|
||||
* 按照官方规范进行压缩
|
||||
*/
|
||||
function performDeflate(input: string): string {
|
||||
try {
|
||||
// 将字符串转换为字节数组
|
||||
const inputBytes = new TextEncoder().encode(input)
|
||||
|
||||
// 使用 fflate 进行 deflate 压缩(最高压缩级别 9)
|
||||
const compressed = deflateSync(inputBytes, { level: 9 })
|
||||
|
||||
// 将压缩后的字节数组转换为二进制字符串
|
||||
return String.fromCharCode(...compressed)
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(`Deflate compression failed:`, error)
|
||||
// 如果压缩失败,返回原始输入
|
||||
return input
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码 PlantUML 代码为服务器可识别的格式
|
||||
* 按照官方规范:UTF-8 编码 -> Deflate 压缩 -> PlantUML Base64 编码
|
||||
*/
|
||||
function encodePlantUML(plantumlCode: string): string {
|
||||
try {
|
||||
// 步骤 1 & 2: UTF-8 编码 + Deflate 压缩
|
||||
const deflated = performDeflate(plantumlCode)
|
||||
|
||||
// 步骤 3: PlantUML 专用的 base64 编码
|
||||
return encode64(deflated)
|
||||
}
|
||||
catch (error) {
|
||||
// 如果编码失败,回退到简单方案
|
||||
console.warn(`PlantUML encoding failed, using fallback:`, error)
|
||||
const utf8Bytes = new TextEncoder().encode(plantumlCode)
|
||||
const base64 = btoa(String.fromCharCode(...utf8Bytes))
|
||||
return `~1${base64.replace(/\+/g, `-`).replace(/\//g, `_`).replace(/=/g, ``)}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 PlantUML 图片 URL
|
||||
*/
|
||||
function generatePlantUMLUrl(code: string, options: Required<PlantUMLOptions>): string {
|
||||
const encoded = encodePlantUML(code)
|
||||
const formatPath = options.format === `svg` ? `svg` : `png`
|
||||
return `${options.serverUrl}/${formatPath}/${encoded}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 PlantUML 图表
|
||||
*/
|
||||
function renderPlantUMLDiagram(token: Tokens.Code, options: Required<PlantUMLOptions>): string {
|
||||
const { text: code } = token
|
||||
|
||||
// 检查代码是否包含 PlantUML 标记
|
||||
const finalCode = (!code.trim().includes(`@start`) || !code.trim().includes(`@end`))
|
||||
? `@startuml\n${code.trim()}\n@enduml`
|
||||
: code
|
||||
|
||||
const imageUrl = generatePlantUMLUrl(finalCode, options)
|
||||
|
||||
// 如果启用了内嵌SVG且格式是SVG
|
||||
if (options.inlineSvg && options.format === `svg`) {
|
||||
// 由于marked是同步的,我们需要返回一个占位符,然后异步替换
|
||||
const placeholder = `plantuml-placeholder-${Math.random().toString(36).slice(2, 11)}`
|
||||
|
||||
// 异步获取SVG内容并替换
|
||||
fetchSvgContent(imageUrl).then((svgContent) => {
|
||||
const placeholderElement = document.querySelector(`[data-placeholder="${placeholder}"]`)
|
||||
if (placeholderElement) {
|
||||
placeholderElement.outerHTML = createPlantUMLHTML(imageUrl, options, svgContent)
|
||||
}
|
||||
})
|
||||
|
||||
const containerStyles = options.styles.container
|
||||
? Object.entries(options.styles.container)
|
||||
.map(([key, value]) => `${key.replace(/([A-Z])/g, `-$1`).toLowerCase()}: ${value}`)
|
||||
.join(`; `)
|
||||
: ``
|
||||
|
||||
return `<div class="${options.className}" style="${containerStyles}" data-placeholder="${placeholder}">
|
||||
<div style="color: #666; font-style: italic;">正在加载PlantUML图表...</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
return createPlantUMLHTML(imageUrl, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取SVG内容
|
||||
*/
|
||||
async function fetchSvgContent(svgUrl: string): Promise<string> {
|
||||
try {
|
||||
const response = await fetch(svgUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const svgContent = await response.text()
|
||||
// 移除SVG根元素的固定尺寸,使其响应式
|
||||
return svgContent
|
||||
// 移除width和height属性
|
||||
.replace(/(<svg[^>]*)\swidth="[^"]*"/g, `$1`)
|
||||
.replace(/(<svg[^>]*)\sheight="[^"]*"/g, `$1`)
|
||||
// 移除style中的width和height
|
||||
.replace(/(<svg[^>]*style="[^"]*?)width:[^;]*;?/g, `$1`)
|
||||
.replace(/(<svg[^>]*style="[^"]*?)height:[^;]*;?/g, `$1`)
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(`Failed to fetch SVG content from ${svgUrl}:`, error)
|
||||
return `<div style="color: #666; font-style: italic;">PlantUML图表加载失败</div>`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 PlantUML HTML 元素
|
||||
*/
|
||||
function createPlantUMLHTML(imageUrl: string, options: Required<PlantUMLOptions>, svgContent?: string): string {
|
||||
const containerStyles = options.styles.container
|
||||
? Object.entries(options.styles.container)
|
||||
.map(([key, value]) => `${key.replace(/([A-Z])/g, `-$1`).toLowerCase()}: ${value}`)
|
||||
.join(`; `)
|
||||
: ``
|
||||
|
||||
// 如果有SVG内容,直接嵌入
|
||||
if (svgContent) {
|
||||
return `<div class="${options.className}" style="${containerStyles}">
|
||||
${svgContent}
|
||||
</div>`
|
||||
}
|
||||
|
||||
// 否则使用图片链接
|
||||
return `<div class="${options.className}" style="${containerStyles}">
|
||||
<img src="${imageUrl}" alt="PlantUML Diagram" style="max-width: 100%; height: auto;" />
|
||||
</div>`
|
||||
}
|
||||
|
||||
/**
|
||||
* PlantUML marked 扩展
|
||||
*/
|
||||
export function markedPlantUML(options: PlantUMLOptions = {}): MarkedExtension {
|
||||
const resolvedOptions: Required<PlantUMLOptions> = {
|
||||
serverUrl: options.serverUrl || `https://www.plantuml.com/plantuml`,
|
||||
format: options.format || `svg`,
|
||||
className: options.className || `plantuml-diagram`,
|
||||
inlineSvg: options.inlineSvg || false,
|
||||
styles: {
|
||||
container: {
|
||||
textAlign: `center`,
|
||||
margin: `16px 8px`,
|
||||
overflowX: `auto`,
|
||||
...options.styles?.container,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
extensions: [
|
||||
{
|
||||
name: `plantuml`,
|
||||
level: `block`,
|
||||
start(src: string) {
|
||||
// 匹配 ```plantuml 代码块
|
||||
return src.match(/^```plantuml/m)?.index
|
||||
},
|
||||
tokenizer(src: string) {
|
||||
// 匹配完整的 plantuml 代码块
|
||||
const match = /^```plantuml\r?\n([\s\S]*?)\r?\n```/.exec(src)
|
||||
|
||||
if (match) {
|
||||
const [raw, code] = match
|
||||
return {
|
||||
type: `plantuml`,
|
||||
raw,
|
||||
text: code.trim(),
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer(token: any) {
|
||||
return renderPlantUMLDiagram(token, resolvedOptions)
|
||||
},
|
||||
},
|
||||
],
|
||||
walkTokens(token: any) {
|
||||
// 处理现有的代码块,如果语言是 plantuml 就转换类型
|
||||
if (token.type === `code` && token.lang === `plantuml`) {
|
||||
token.type = `plantuml`
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { MarkedExtension } from 'marked'
|
||||
|
||||
/**
|
||||
* 注音/拼音标注扩展
|
||||
* https://talk.commonmark.org/t/proper-ruby-text-rb-syntax-support-in-markdown/2279
|
||||
* https://www.w3.org/TR/ruby/
|
||||
*
|
||||
* 支持的格式:
|
||||
* 1. [文字]{注音}
|
||||
* 2. [文字]^(注音)
|
||||
*
|
||||
* 分隔符:
|
||||
* - `・` (中点)
|
||||
* - `.` (全角句点)
|
||||
* - `。` (中文句号)
|
||||
* - `-` (英文减号)
|
||||
*/
|
||||
export function markedRuby(): MarkedExtension {
|
||||
return {
|
||||
extensions: [
|
||||
{
|
||||
name: `ruby`,
|
||||
level: `inline`,
|
||||
start(src: string) {
|
||||
// 匹配以 [ 开头的格式
|
||||
return src.match(/\[/)?.index
|
||||
},
|
||||
tokenizer(src: string) {
|
||||
// 1. [文字]{注音}
|
||||
const rule1 = /^\[([^\]]+)\]\{([^}]+)\}/
|
||||
let match = rule1.exec(src)
|
||||
if (match) {
|
||||
return {
|
||||
type: `ruby`,
|
||||
raw: match[0],
|
||||
text: match[1].trim(),
|
||||
ruby: match[2].trim(),
|
||||
format: `basic`,
|
||||
}
|
||||
}
|
||||
|
||||
// 2. [文字]^(注音)
|
||||
const rule2 = /^\[([^\]]+)\]\^\(([^)]+)\)/
|
||||
match = rule2.exec(src)
|
||||
if (match) {
|
||||
return {
|
||||
type: `ruby`,
|
||||
raw: match[0],
|
||||
text: match[1].trim(),
|
||||
ruby: match[2].trim(),
|
||||
format: `basic-hat`,
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
},
|
||||
renderer(token: any) {
|
||||
const { text, ruby, format } = token
|
||||
|
||||
// 检查是否有分隔符
|
||||
const separatorRegex = /[・.。-]/g
|
||||
const hasSeparators = separatorRegex.test(ruby)
|
||||
|
||||
if (hasSeparators) {
|
||||
// 分割注音部分
|
||||
const rubyParts = ruby.split(separatorRegex).filter((part: string) => part.trim() !== ``)
|
||||
|
||||
const textChars = text.split(``)
|
||||
const result = []
|
||||
|
||||
if (textChars.length >= rubyParts.length) {
|
||||
// 文字字符数量 >= 注音部分数量
|
||||
// 按注音部分数量分割文字
|
||||
let currentIndex = 0
|
||||
|
||||
for (let i = 0; i < rubyParts.length; i++) {
|
||||
const rubyPart = rubyParts[i]
|
||||
const remainingChars = textChars.length - currentIndex
|
||||
const remainingParts = rubyParts.length - i
|
||||
|
||||
// 计算当前部分应该包含多少个字符,默认为 1
|
||||
let charCount = 1
|
||||
if (remainingParts === 1) {
|
||||
// 最后一个部分,包含所有剩余字符
|
||||
charCount = remainingChars
|
||||
}
|
||||
|
||||
// 提取当前部分的文字
|
||||
const currentText = textChars.slice(currentIndex, currentIndex + charCount).join(``)
|
||||
|
||||
result.push(`<ruby data-text="${currentText}" data-ruby="${rubyPart}" data-format="${format}">${currentText}<rp>(</rp><rt>${rubyPart}</rt><rp>)</rp></ruby>`)
|
||||
|
||||
currentIndex += charCount
|
||||
}
|
||||
|
||||
// 处理剩余的字符
|
||||
if (currentIndex < textChars.length) {
|
||||
result.push(textChars.slice(currentIndex).join(``))
|
||||
}
|
||||
}
|
||||
else {
|
||||
// 文字字符数量 < 注音部分数量
|
||||
// 每个字符对应一个注音部分,多余的注音被忽略
|
||||
for (let i = 0; i < textChars.length; i++) {
|
||||
const char = textChars[i]
|
||||
const rubyPart = rubyParts[i] || ``
|
||||
|
||||
if (rubyPart) {
|
||||
result.push(`<ruby data-text="${char}" data-ruby="${rubyPart}" data-format="${format}">${char}<rp>(</rp><rt>${rubyPart}</rt><rp>)</rp></ruby>`)
|
||||
}
|
||||
else {
|
||||
result.push(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.join(``)
|
||||
}
|
||||
|
||||
return `<ruby data-text="${text}" data-ruby="${ruby}" data-format="${format}">${text}<rp>(</rp><rt>${ruby}</rt><rp>)</rp></ruby>`
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { MarkedExtension, Tokens } from 'marked'
|
||||
|
||||
/**
|
||||
* A marked extension to support horizontal sliding images.
|
||||
* Syntax: <,,>
|
||||
*/
|
||||
export function markedSlider(): MarkedExtension {
|
||||
return {
|
||||
extensions: [
|
||||
{
|
||||
name: `horizontalSlider`,
|
||||
level: `block`,
|
||||
start(src: string) {
|
||||
return src.match(/^<!\[/)?.index
|
||||
},
|
||||
tokenizer(src: string) {
|
||||
const rule = /^<(!\[.*?\]\(.*?\)(?:,!\[.*?\]\(.*?\))*)>/
|
||||
const match = src.match(rule)
|
||||
if (match) {
|
||||
return {
|
||||
type: `horizontalSlider`,
|
||||
raw: match[0],
|
||||
text: match[1],
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
renderer(token: Tokens.Generic) {
|
||||
const { text } = token
|
||||
const imageMatches = text.match(/!\[(.*?)\]\((.*?)\)/g) || []
|
||||
|
||||
if (imageMatches.length === 0) {
|
||||
return ``
|
||||
}
|
||||
|
||||
const images = imageMatches.map((img: string) => {
|
||||
const altMatch = img.match(/!\[(.*?)\]/) || []
|
||||
const srcMatch = img.match(/\]\((.*?)\)/) || []
|
||||
const alt = altMatch[1] || ``
|
||||
const src = srcMatch[1] || ``
|
||||
|
||||
// 新主题系统:不再需要内联样式
|
||||
return { src, alt }
|
||||
})
|
||||
|
||||
// 使用微信公众号兼容的滑动容器布局
|
||||
// 使用微信支持的section标签和特殊样式组合
|
||||
|
||||
return `
|
||||
<section style="box-sizing: border-box; font-size: 16px;">
|
||||
<section data-role="outer" style="font-family: 微软雅黑; font-size: 16px;">
|
||||
<section data-role="paragraph" style="margin: 0px auto; box-sizing: border-box; width: 100%;">
|
||||
<section style="margin: 0px auto; text-align: center;">
|
||||
<section style="display: inline-block; width: 100%;">
|
||||
<!-- 微信公众号支持的滑动图片容器 -->
|
||||
<section style="overflow-x: scroll; -webkit-overflow-scrolling: touch; white-space: nowrap; width: 100%; text-align: center;">
|
||||
${images.map((img: { src: string, alt: string }, _index: number) => `<section style="display: inline-block; width: 100%; margin-right: 0; vertical-align: top;">
|
||||
<img src="${img.src}" alt="${img.alt}" title="${img.alt}" style="width: 100%; height: auto; border-radius: 4px; vertical-align: top;"/>
|
||||
<p style="margin-top: 5px; font-size: 14px; color: #666; text-align: center; white-space: normal;">${img.alt}</p>
|
||||
</section>`).join(``)}
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
<p style="font-size: 14px; color: #999; text-align: center; margin-top: 5px;"><<< 左右滑动看更多 >>></p>
|
||||
</section>
|
||||
`
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { MarkedExtension } from 'marked'
|
||||
|
||||
/**
|
||||
* marked 插件:支持 [TOC] 语法,自动生成嵌套目录
|
||||
*/
|
||||
export function markedToc(): MarkedExtension {
|
||||
let headings: { text: string, depth: number, index: number }[] = []
|
||||
|
||||
let firstToken = true
|
||||
|
||||
return {
|
||||
walkTokens(token) {
|
||||
if (firstToken) {
|
||||
headings = []
|
||||
firstToken = false
|
||||
}
|
||||
if (token.type === `heading`) {
|
||||
const text = token.text || ``
|
||||
const depth = token.depth || 1
|
||||
const index = headings.length
|
||||
headings.push({ text, depth, index })
|
||||
}
|
||||
},
|
||||
extensions: [
|
||||
{
|
||||
name: `toc`,
|
||||
level: `block`,
|
||||
start(src) {
|
||||
// 只匹配独立一行的 [TOC],避免误伤
|
||||
const match = src.match(/^\s*\[TOC\]\s*$/m)
|
||||
return match ? match.index : undefined
|
||||
},
|
||||
tokenizer(src) {
|
||||
const match = /^\[TOC\]/.exec(src)
|
||||
if (match) {
|
||||
return {
|
||||
type: `toc`,
|
||||
raw: match[0],
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer() {
|
||||
if (!headings.length)
|
||||
return ``
|
||||
let html = `<nav class="markdown-toc"><ul class="toc-ul toc-level-1 pl-4 border-l ml-2">`
|
||||
let lastDepth = 1
|
||||
headings.forEach(({ text, depth, index }) => {
|
||||
if (depth > lastDepth) {
|
||||
for (let i = lastDepth + 1; i <= depth; i++) {
|
||||
html += `<ul class="toc-ul toc-level-${i} pl-4 border-l ml-2">`
|
||||
}
|
||||
}
|
||||
else if (depth < lastDepth) {
|
||||
for (let i = lastDepth; i > depth; i--) {
|
||||
html += `</ul>`
|
||||
}
|
||||
}
|
||||
html += `<li class="toc-li toc-level-${depth} mb-1"><a class="text-gray-700 hover:text-blue-600 underline transition-colors" href="#${index}">${text}</a></li>`
|
||||
lastDepth = depth
|
||||
})
|
||||
|
||||
for (let i = lastDepth; i > 1; i--) {
|
||||
html += `</ul>`
|
||||
}
|
||||
|
||||
html += `</ul></nav>`
|
||||
|
||||
firstToken = true
|
||||
return html
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import frontMatter from "front-matter";
|
||||
import hljs from "highlight.js/lib/core";
|
||||
import { marked, type RendererObject, type Tokens } from "marked";
|
||||
import readingTime, { type ReadTimeResults } from "reading-time";
|
||||
|
||||
import {
|
||||
markedAlert,
|
||||
markedFootnotes,
|
||||
markedInfographic,
|
||||
markedMarkup,
|
||||
markedPlantUML,
|
||||
markedRuby,
|
||||
markedSlider,
|
||||
markedToc,
|
||||
MDKatex,
|
||||
} from "./extensions/index.js";
|
||||
import {
|
||||
COMMON_LANGUAGES,
|
||||
highlightAndFormatCode,
|
||||
} from "./utils/languages.js";
|
||||
|
||||
type ThemeName = "default" | "grace" | "simple";
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
|
||||
const THEME_NAMES: ThemeName[] = ["default", "grace", "simple"];
|
||||
|
||||
const DEFAULT_STYLE = {
|
||||
primaryColor: "#0F4C81",
|
||||
fontFamily:
|
||||
"-apple-system-font,BlinkMacSystemFont, Helvetica Neue, PingFang SC, Hiragino Sans GB , Microsoft YaHei UI , Microsoft YaHei ,Arial,sans-serif",
|
||||
fontSize: "16px",
|
||||
foreground: "0 0% 3.9%",
|
||||
blockquoteBackground: "#f7f7f7",
|
||||
};
|
||||
|
||||
Object.entries(COMMON_LANGUAGES).forEach(([name, lang]) => {
|
||||
hljs.registerLanguage(name, lang);
|
||||
});
|
||||
|
||||
export { hljs };
|
||||
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
});
|
||||
marked.use(markedSlider());
|
||||
|
||||
interface IOpts {
|
||||
legend?: string;
|
||||
citeStatus?: boolean;
|
||||
countStatus?: boolean;
|
||||
isMacCodeBlock?: boolean;
|
||||
isShowLineNumber?: boolean;
|
||||
themeMode?: "light" | "dark";
|
||||
}
|
||||
|
||||
interface RendererAPI {
|
||||
reset: (newOpts: Partial<IOpts>) => void;
|
||||
setOptions: (newOpts: Partial<IOpts>) => void;
|
||||
getOpts: () => IOpts;
|
||||
parseFrontMatterAndContent: (markdown: string) => {
|
||||
yamlData: Record<string, any>;
|
||||
markdownContent: string;
|
||||
readingTime: ReadTimeResults;
|
||||
};
|
||||
buildReadingTime: (reading: ReadTimeResults) => string;
|
||||
buildFootnotes: () => string;
|
||||
buildAddition: () => string;
|
||||
createContainer: (html: string) => string;
|
||||
}
|
||||
|
||||
interface ParseResult {
|
||||
yamlData: Record<string, any>;
|
||||
markdownContent: string;
|
||||
readingTime: ReadTimeResults;
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/`/g, "`");
|
||||
}
|
||||
|
||||
function buildAddition(): string {
|
||||
return `
|
||||
<style>
|
||||
.preview-wrapper pre::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
color: #ccc;
|
||||
text-align: center;
|
||||
font-size: 0.8em;
|
||||
padding: 5px 10px 0;
|
||||
line-height: 15px;
|
||||
height: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
function buildFootnoteArray(footnotes: [number, string, string][]): string {
|
||||
return footnotes
|
||||
.map(([index, title, link]) =>
|
||||
link === title
|
||||
? `<code style="font-size: 90%; opacity: 0.6;">[${index}]</code>: <i style="word-break: break-all">${title}</i><br/>`
|
||||
: `<code style="font-size: 90%; opacity: 0.6;">[${index}]</code> ${title}: <i style="word-break: break-all">${link}</i><br/>`
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function transform(legend: string, text: string | null, title: string | null): string {
|
||||
const options = legend.split("-");
|
||||
for (const option of options) {
|
||||
if (option === "alt" && text) {
|
||||
return text;
|
||||
}
|
||||
if (option === "title" && title) {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const macCodeSvg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" x="0px" y="0px" width="45px" height="13px" viewBox="0 0 450 130">
|
||||
<ellipse cx="50" cy="65" rx="50" ry="52" stroke="rgb(220,60,54)" stroke-width="2" fill="rgb(237,108,96)" />
|
||||
<ellipse cx="225" cy="65" rx="50" ry="52" stroke="rgb(218,151,33)" stroke-width="2" fill="rgb(247,193,81)" />
|
||||
<ellipse cx="400" cy="65" rx="50" ry="52" stroke="rgb(27,161,37)" stroke-width="2" fill="rgb(100,200,86)" />
|
||||
</svg>
|
||||
`.trim();
|
||||
|
||||
function parseFrontMatterAndContent(markdownText: string): ParseResult {
|
||||
try {
|
||||
const parsed = frontMatter(markdownText);
|
||||
const yamlData = parsed.attributes;
|
||||
const markdownContent = parsed.body;
|
||||
|
||||
const readingTimeResult = readingTime(markdownContent);
|
||||
|
||||
return {
|
||||
yamlData: yamlData as Record<string, any>,
|
||||
markdownContent,
|
||||
readingTime: readingTimeResult,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error parsing front-matter:", error);
|
||||
return {
|
||||
yamlData: {},
|
||||
markdownContent: markdownText,
|
||||
readingTime: readingTime(markdownText),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function initRenderer(opts: IOpts = {}): RendererAPI {
|
||||
const footnotes: [number, string, string][] = [];
|
||||
let footnoteIndex = 0;
|
||||
let codeIndex = 0;
|
||||
const listOrderedStack: boolean[] = [];
|
||||
const listCounters: number[] = [];
|
||||
const isBrowser = typeof window !== "undefined";
|
||||
|
||||
function getOpts(): IOpts {
|
||||
return opts;
|
||||
}
|
||||
|
||||
function styledContent(styleLabel: string, content: string, tagName?: string): string {
|
||||
const tag = tagName ?? styleLabel;
|
||||
const className = `${styleLabel.replace(/_/g, "-")}`;
|
||||
const headingAttr = /^h\d$/.test(tag) ? " data-heading=\"true\"" : "";
|
||||
return `<${tag} class="${className}"${headingAttr}>${content}</${tag}>`;
|
||||
}
|
||||
|
||||
function addFootnote(title: string, link: string): number {
|
||||
const existingFootnote = footnotes.find(([, , existingLink]) => existingLink === link);
|
||||
if (existingFootnote) {
|
||||
return existingFootnote[0];
|
||||
}
|
||||
|
||||
footnotes.push([++footnoteIndex, title, link]);
|
||||
return footnoteIndex;
|
||||
}
|
||||
|
||||
function reset(newOpts: Partial<IOpts>): void {
|
||||
footnotes.length = 0;
|
||||
footnoteIndex = 0;
|
||||
setOptions(newOpts);
|
||||
}
|
||||
|
||||
function setOptions(newOpts: Partial<IOpts>): void {
|
||||
opts = { ...opts, ...newOpts };
|
||||
marked.use(markedAlert());
|
||||
if (isBrowser) {
|
||||
marked.use(MDKatex({ nonStandard: true }, true));
|
||||
}
|
||||
marked.use(markedMarkup());
|
||||
marked.use(markedInfographic({ themeMode: opts.themeMode }));
|
||||
}
|
||||
|
||||
function buildReadingTime(readingTimeResult: ReadTimeResults): string {
|
||||
if (!opts.countStatus) {
|
||||
return "";
|
||||
}
|
||||
if (!readingTimeResult.words) {
|
||||
return "";
|
||||
}
|
||||
return `
|
||||
<blockquote class="md-blockquote">
|
||||
<p class="md-blockquote-p">字数 ${readingTimeResult?.words},阅读大约需 ${Math.ceil(readingTimeResult?.minutes)} 分钟</p>
|
||||
</blockquote>
|
||||
`;
|
||||
}
|
||||
|
||||
const buildFootnotes = () => {
|
||||
if (!footnotes.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return (
|
||||
styledContent("h4", "引用链接")
|
||||
+ styledContent("footnotes", buildFootnoteArray(footnotes), "p")
|
||||
);
|
||||
};
|
||||
|
||||
const renderer: RendererObject = {
|
||||
heading({ tokens, depth }: Tokens.Heading) {
|
||||
const text = this.parser.parseInline(tokens);
|
||||
const tag = `h${depth}`;
|
||||
return styledContent(tag, text);
|
||||
},
|
||||
|
||||
paragraph({ tokens }: Tokens.Paragraph): string {
|
||||
const text = this.parser.parseInline(tokens);
|
||||
const isFigureImage = text.includes("<figure") && text.includes("<img");
|
||||
const isEmpty = text.trim() === "";
|
||||
if (isFigureImage || isEmpty) {
|
||||
return text;
|
||||
}
|
||||
return styledContent("p", text);
|
||||
},
|
||||
|
||||
blockquote({ tokens }: Tokens.Blockquote): string {
|
||||
const text = this.parser.parse(tokens);
|
||||
return styledContent("blockquote", text);
|
||||
},
|
||||
|
||||
code({ text, lang = "" }: Tokens.Code): string {
|
||||
if (lang.startsWith("mermaid")) {
|
||||
if (isBrowser) {
|
||||
clearTimeout(codeIndex as any);
|
||||
codeIndex = setTimeout(async () => {
|
||||
const windowRef = typeof window !== "undefined" ? (window as any) : undefined;
|
||||
if (windowRef && windowRef.mermaid) {
|
||||
const mermaid = windowRef.mermaid;
|
||||
await mermaid.run();
|
||||
} else {
|
||||
const mermaid = await import("mermaid");
|
||||
await mermaid.default.run();
|
||||
}
|
||||
}, 0) as any as number;
|
||||
}
|
||||
return `<pre class="mermaid">${text}</pre>`;
|
||||
}
|
||||
const langText = lang.split(" ")[0];
|
||||
const isLanguageRegistered = hljs.getLanguage(langText);
|
||||
const language = isLanguageRegistered ? langText : "plaintext";
|
||||
|
||||
const highlighted = highlightAndFormatCode(
|
||||
text,
|
||||
language,
|
||||
hljs,
|
||||
!!opts.isShowLineNumber
|
||||
);
|
||||
|
||||
const span = `<span class="mac-sign" style="padding: 10px 14px 0;">${macCodeSvg}</span>`;
|
||||
let pendingAttr = "";
|
||||
if (!isLanguageRegistered && langText !== "plaintext") {
|
||||
const escapedText = text.replace(/"/g, """);
|
||||
pendingAttr = ` data-language-pending="${langText}" data-raw-code="${escapedText}" data-show-line-number="${opts.isShowLineNumber}"`;
|
||||
}
|
||||
const code = `<code class="language-${lang}"${pendingAttr}>${highlighted}</code>`;
|
||||
|
||||
return `<pre class="hljs code__pre">${span}${code}</pre>`;
|
||||
},
|
||||
|
||||
codespan({ text }: Tokens.Codespan): string {
|
||||
const escapedText = escapeHtml(text);
|
||||
return styledContent("codespan", escapedText, "code");
|
||||
},
|
||||
|
||||
list({ ordered, items, start = 1 }: Tokens.List) {
|
||||
listOrderedStack.push(ordered);
|
||||
listCounters.push(Number(start));
|
||||
|
||||
const html = items.map((item) => this.listitem(item)).join("");
|
||||
|
||||
listOrderedStack.pop();
|
||||
listCounters.pop();
|
||||
|
||||
return styledContent(ordered ? "ol" : "ul", html);
|
||||
},
|
||||
|
||||
listitem(token: Tokens.ListItem) {
|
||||
const ordered = listOrderedStack[listOrderedStack.length - 1];
|
||||
const idx = listCounters[listCounters.length - 1]!;
|
||||
|
||||
listCounters[listCounters.length - 1] = idx + 1;
|
||||
|
||||
const prefix = ordered ? `${idx}. ` : "• ";
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = this.parser.parseInline(token.tokens);
|
||||
} catch {
|
||||
content = this.parser
|
||||
.parse(token.tokens)
|
||||
.replace(/^<p(?:\s[^>]*)?>([\s\S]*?)<\/p>/, "$1");
|
||||
}
|
||||
|
||||
return styledContent("listitem", `${prefix}${content}`, "li");
|
||||
},
|
||||
|
||||
image({ href, title, text }: Tokens.Image): string {
|
||||
const newText = opts.legend ? transform(opts.legend, text, title) : "";
|
||||
const subText = newText ? styledContent("figcaption", newText) : "";
|
||||
const titleAttr = title ? ` title="${title}"` : "";
|
||||
return `<figure><img src="${href}"${titleAttr} alt="${text}"/>${subText}</figure>`;
|
||||
},
|
||||
|
||||
link({ href, title, text, tokens }: Tokens.Link): string {
|
||||
const parsedText = this.parser.parseInline(tokens);
|
||||
if (/^https?:\/\/mp\.weixin\.qq\.com/.test(href)) {
|
||||
return `<a href="${href}" title="${title || text}">${parsedText}</a>`;
|
||||
}
|
||||
if (href === text) {
|
||||
return parsedText;
|
||||
}
|
||||
if (opts.citeStatus) {
|
||||
const ref = addFootnote(title || text, href);
|
||||
return `<a href="${href}" title="${title || text}">${parsedText}<sup>[${ref}]</sup></a>`;
|
||||
}
|
||||
return `<a href="${href}" title="${title || text}">${parsedText}</a>`;
|
||||
},
|
||||
|
||||
strong({ tokens }: Tokens.Strong): string {
|
||||
return styledContent("strong", this.parser.parseInline(tokens));
|
||||
},
|
||||
|
||||
em({ tokens }: Tokens.Em): string {
|
||||
return styledContent("em", this.parser.parseInline(tokens));
|
||||
},
|
||||
|
||||
table({ header, rows }: Tokens.Table): string {
|
||||
const headerRow = header
|
||||
.map((cell) => {
|
||||
const text = this.parser.parseInline(cell.tokens);
|
||||
return styledContent("th", text);
|
||||
})
|
||||
.join("");
|
||||
const body = rows
|
||||
.map((row) => {
|
||||
const rowContent = row.map((cell) => this.tablecell(cell)).join("");
|
||||
return styledContent("tr", rowContent);
|
||||
})
|
||||
.join("");
|
||||
return `
|
||||
<section style="max-width: 100%; overflow: auto">
|
||||
<table class="preview-table">
|
||||
<thead>${headerRow}</thead>
|
||||
<tbody>${body}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
`;
|
||||
},
|
||||
|
||||
tablecell(token: Tokens.TableCell): string {
|
||||
const text = this.parser.parseInline(token.tokens);
|
||||
return styledContent("td", text);
|
||||
},
|
||||
|
||||
hr(_: Tokens.Hr): string {
|
||||
return styledContent("hr", "");
|
||||
},
|
||||
};
|
||||
|
||||
marked.use({ renderer });
|
||||
marked.use(markedMarkup());
|
||||
marked.use(markedToc());
|
||||
marked.use(markedSlider());
|
||||
marked.use(markedAlert({}));
|
||||
if (isBrowser) {
|
||||
marked.use(MDKatex({ nonStandard: true }, true));
|
||||
}
|
||||
marked.use(markedFootnotes());
|
||||
marked.use(
|
||||
markedPlantUML({
|
||||
inlineSvg: isBrowser,
|
||||
})
|
||||
);
|
||||
marked.use(markedInfographic());
|
||||
marked.use(markedRuby());
|
||||
|
||||
return {
|
||||
buildAddition,
|
||||
buildFootnotes,
|
||||
setOptions,
|
||||
reset,
|
||||
parseFrontMatterAndContent,
|
||||
buildReadingTime,
|
||||
createContainer(content: string) {
|
||||
return styledContent("container", content, "section");
|
||||
},
|
||||
getOpts,
|
||||
};
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
console.error(
|
||||
[
|
||||
"Usage:",
|
||||
" npx tsx src/md/render.ts <markdown_file> [--theme <name>]",
|
||||
"",
|
||||
"Options:",
|
||||
` --theme Theme name (${THEME_NAMES.join(", ")})`,
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions | null {
|
||||
let inputPath = "";
|
||||
let theme: ThemeName = "default";
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (!arg.startsWith("--") && !inputPath) {
|
||||
inputPath = arg;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--theme") {
|
||||
theme = (argv[i + 1] || "") as ThemeName;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith("--theme=")) {
|
||||
theme = arg.slice("--theme=".length) as ThemeName;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
return null;
|
||||
}
|
||||
|
||||
console.error(`Unknown argument: ${arg}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!inputPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!THEME_NAMES.includes(theme)) {
|
||||
console.error(`Unknown theme: ${theme}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
inputPath,
|
||||
theme,
|
||||
};
|
||||
}
|
||||
|
||||
interface CliOptions {
|
||||
inputPath: string;
|
||||
theme: ThemeName;
|
||||
}
|
||||
|
||||
function renderMarkdown(raw: string, renderer: RendererAPI): {
|
||||
html: string;
|
||||
readingTime: ReadTimeResults;
|
||||
} {
|
||||
const { markdownContent, readingTime: readingTimeResult } =
|
||||
renderer.parseFrontMatterAndContent(raw);
|
||||
|
||||
const html = marked.parse(markdownContent) as string;
|
||||
|
||||
return { html, readingTime: readingTimeResult };
|
||||
}
|
||||
|
||||
function postProcessHtml(
|
||||
baseHtml: string,
|
||||
reading: ReadTimeResults,
|
||||
renderer: RendererAPI
|
||||
): string {
|
||||
let html = baseHtml;
|
||||
html = renderer.buildReadingTime(reading) + html;
|
||||
html += renderer.buildFootnotes();
|
||||
html += renderer.buildAddition();
|
||||
html += `
|
||||
<style>
|
||||
.hljs.code__pre > .mac-sign {
|
||||
display: ${renderer.getOpts().isMacCodeBlock ? "flex" : "none"};
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
html += `
|
||||
<style>
|
||||
h2 strong {
|
||||
color: inherit !important;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
return renderer.createContainer(html);
|
||||
}
|
||||
|
||||
function formatTimestamp(date = new Date()): string {
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(
|
||||
date.getDate()
|
||||
)}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function ensureMarkdownPath(inputPath: string): void {
|
||||
if (!inputPath.toLowerCase().endsWith(".md")) {
|
||||
throw new Error("Input file must end with .md");
|
||||
}
|
||||
}
|
||||
|
||||
function loadThemeCss(theme: ThemeName): {
|
||||
baseCss: string;
|
||||
themeCss: string;
|
||||
} {
|
||||
const basePath = path.join(THEME_DIR, "base.css");
|
||||
const themePath = path.join(THEME_DIR, `${theme}.css`);
|
||||
|
||||
if (!fs.existsSync(basePath)) {
|
||||
throw new Error(`Missing base CSS: ${basePath}`);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(themePath)) {
|
||||
throw new Error(`Missing theme CSS: ${themePath}`);
|
||||
}
|
||||
|
||||
return {
|
||||
baseCss: fs.readFileSync(basePath, "utf-8"),
|
||||
themeCss: fs.readFileSync(themePath, "utf-8"),
|
||||
};
|
||||
}
|
||||
|
||||
function buildCss(baseCss: string, themeCss: string): string {
|
||||
const variables = `
|
||||
:root {
|
||||
--md-primary-color: ${DEFAULT_STYLE.primaryColor};
|
||||
--md-font-family: ${DEFAULT_STYLE.fontFamily};
|
||||
--md-font-size: ${DEFAULT_STYLE.fontSize};
|
||||
--foreground: ${DEFAULT_STYLE.foreground};
|
||||
--blockquote-background: ${DEFAULT_STYLE.blockquoteBackground};
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
#output {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
`.trim();
|
||||
|
||||
return [variables, baseCss, themeCss].join("\n\n");
|
||||
}
|
||||
|
||||
function buildHtmlDocument(title: string, css: string, html: string): string {
|
||||
return [
|
||||
"<!doctype html>",
|
||||
"<html>",
|
||||
"<head>",
|
||||
' <meta charset="utf-8" />',
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
||||
` <title>${title}</title>`,
|
||||
` <style>${css}</style>`,
|
||||
"</head>",
|
||||
"<body>",
|
||||
' <div id="output">',
|
||||
html,
|
||||
" </div>",
|
||||
"</body>",
|
||||
"</html>",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (!options) {
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const inputPath = path.resolve(process.cwd(), options.inputPath);
|
||||
ensureMarkdownPath(inputPath);
|
||||
|
||||
if (!fs.existsSync(inputPath)) {
|
||||
console.error(`File not found: ${inputPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const outputPath = path.resolve(
|
||||
process.cwd(),
|
||||
options.inputPath.replace(/\.md$/i, ".html")
|
||||
);
|
||||
|
||||
const { baseCss, themeCss } = loadThemeCss(options.theme);
|
||||
const css = buildCss(baseCss, themeCss);
|
||||
const markdown = fs.readFileSync(inputPath, "utf-8");
|
||||
|
||||
const renderer = initRenderer({});
|
||||
const { html: baseHtml, readingTime: readingTimeResult } = renderMarkdown(
|
||||
markdown,
|
||||
renderer
|
||||
);
|
||||
const content = postProcessHtml(baseHtml, readingTimeResult, renderer);
|
||||
|
||||
const title = path.basename(outputPath, ".html");
|
||||
const html = buildHtmlDocument(title, css, content);
|
||||
|
||||
let backupPath = "";
|
||||
if (fs.existsSync(outputPath)) {
|
||||
backupPath = `${outputPath}.bak-${formatTimestamp()}`;
|
||||
fs.renameSync(outputPath, backupPath);
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputPath, html, "utf-8");
|
||||
|
||||
if (backupPath) {
|
||||
console.log(`Backup created: ${backupPath}`);
|
||||
}
|
||||
console.log(`HTML written: ${outputPath}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* MD 基础主题样式
|
||||
* 包含所有元素的基础样式和 CSS 变量定义
|
||||
*/
|
||||
|
||||
/* ==================== 容器样式 ==================== */
|
||||
section,
|
||||
container {
|
||||
font-family: var(--md-font-family);
|
||||
font-size: var(--md-font-size);
|
||||
line-height: 1.75;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 确保 #output 容器应用基础样式 */
|
||||
#output {
|
||||
font-family: var(--md-font-family);
|
||||
font-size: var(--md-font-size);
|
||||
line-height: 1.75;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 去除第一个元素的 margin-top */
|
||||
#output section > :first-child {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* MD 默认主题(经典主题)
|
||||
* 按 Alt/Option + Shift + F 可格式化
|
||||
* 如需使用主题色,请使用 var(--md-primary-color) 代替颜色值
|
||||
*/
|
||||
|
||||
/* ==================== 一级标题 ==================== */
|
||||
h1 {
|
||||
display: table;
|
||||
padding: 0 1em;
|
||||
border-bottom: 2px solid var(--md-primary-color);
|
||||
margin: 2em auto 1em;
|
||||
color: hsl(var(--foreground));
|
||||
font-size: calc(var(--md-font-size) * 1.2);
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ==================== 二级标题 ==================== */
|
||||
h2 {
|
||||
display: table;
|
||||
padding: 0 0.2em;
|
||||
margin: 4em auto 2em;
|
||||
color: #fff;
|
||||
background: var(--md-primary-color);
|
||||
font-size: calc(var(--md-font-size) * 1.2);
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ==================== 三级标题 ==================== */
|
||||
h3 {
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid var(--md-primary-color);
|
||||
margin: 2em 8px 0.75em 0;
|
||||
color: hsl(var(--foreground));
|
||||
font-size: calc(var(--md-font-size) * 1.1);
|
||||
font-weight: bold;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* ==================== 四级标题 ==================== */
|
||||
h4 {
|
||||
margin: 2em 8px 0.5em;
|
||||
color: var(--md-primary-color);
|
||||
font-size: calc(var(--md-font-size) * 1);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* ==================== 五级标题 ==================== */
|
||||
h5 {
|
||||
margin: 1.5em 8px 0.5em;
|
||||
color: var(--md-primary-color);
|
||||
font-size: calc(var(--md-font-size) * 1);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* ==================== 六级标题 ==================== */
|
||||
h6 {
|
||||
margin: 1.5em 8px 0.5em;
|
||||
font-size: calc(var(--md-font-size) * 1);
|
||||
color: var(--md-primary-color);
|
||||
}
|
||||
|
||||
/* ==================== 段落 ==================== */
|
||||
p {
|
||||
margin: 1.5em 8px;
|
||||
letter-spacing: 0.1em;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* ==================== 引用块 ==================== */
|
||||
blockquote {
|
||||
font-style: normal;
|
||||
padding: 1em;
|
||||
border-left: 4px solid var(--md-primary-color);
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--foreground));
|
||||
background: var(--blockquote-background);
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
blockquote > p {
|
||||
display: block;
|
||||
font-size: 1em;
|
||||
letter-spacing: 0.1em;
|
||||
color: hsl(var(--foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ==================== GFM 警告块 ==================== */
|
||||
.alert-title-note,
|
||||
.alert-title-tip,
|
||||
.alert-title-info,
|
||||
.alert-title-important,
|
||||
.alert-title-warning,
|
||||
.alert-title-caution,
|
||||
.alert-title-abstract,
|
||||
.alert-title-summary,
|
||||
.alert-title-tldr,
|
||||
.alert-title-todo,
|
||||
.alert-title-success,
|
||||
.alert-title-done,
|
||||
.alert-title-question,
|
||||
.alert-title-help,
|
||||
.alert-title-faq,
|
||||
.alert-title-failure,
|
||||
.alert-title-fail,
|
||||
.alert-title-missing,
|
||||
.alert-title-danger,
|
||||
.alert-title-error,
|
||||
.alert-title-bug,
|
||||
.alert-title-example,
|
||||
.alert-title-quote,
|
||||
.alert-title-cite {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.alert-title-note {
|
||||
color: #478be6;
|
||||
}
|
||||
|
||||
.alert-title-tip {
|
||||
color: #57ab5a;
|
||||
}
|
||||
|
||||
.alert-title-info {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.alert-title-important {
|
||||
color: #986ee2;
|
||||
}
|
||||
|
||||
.alert-title-warning {
|
||||
color: #c69026;
|
||||
}
|
||||
|
||||
.alert-title-caution {
|
||||
color: #e5534b;
|
||||
}
|
||||
|
||||
/* Obsidian-style callout colors */
|
||||
.alert-title-abstract,
|
||||
.alert-title-summary,
|
||||
.alert-title-tldr {
|
||||
color: #00bfff;
|
||||
}
|
||||
|
||||
.alert-title-todo {
|
||||
color: #478be6;
|
||||
}
|
||||
|
||||
.alert-title-success,
|
||||
.alert-title-done {
|
||||
color: #57ab5a;
|
||||
}
|
||||
|
||||
.alert-title-question,
|
||||
.alert-title-help,
|
||||
.alert-title-faq {
|
||||
color: #c69026;
|
||||
}
|
||||
|
||||
.alert-title-failure,
|
||||
.alert-title-fail,
|
||||
.alert-title-missing {
|
||||
color: #e5534b;
|
||||
}
|
||||
|
||||
.alert-title-danger,
|
||||
.alert-title-error {
|
||||
color: #e5534b;
|
||||
}
|
||||
|
||||
.alert-title-bug {
|
||||
color: #e5534b;
|
||||
}
|
||||
|
||||
.alert-title-example {
|
||||
color: #986ee2;
|
||||
}
|
||||
|
||||
.alert-title-quote,
|
||||
.alert-title-cite {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* GFM Alert SVG 图标颜色 */
|
||||
.alert-icon-note {
|
||||
fill: #478be6;
|
||||
}
|
||||
|
||||
.alert-icon-tip {
|
||||
fill: #57ab5a;
|
||||
}
|
||||
|
||||
.alert-icon-info {
|
||||
fill: #93c5fd;
|
||||
}
|
||||
|
||||
.alert-icon-important {
|
||||
fill: #986ee2;
|
||||
}
|
||||
|
||||
.alert-icon-warning {
|
||||
fill: #c69026;
|
||||
}
|
||||
|
||||
.alert-icon-caution {
|
||||
fill: #e5534b;
|
||||
}
|
||||
|
||||
/* Obsidian-style callout icon colors */
|
||||
.alert-icon-abstract,
|
||||
.alert-icon-summary,
|
||||
.alert-icon-tldr {
|
||||
fill: #00bfff;
|
||||
}
|
||||
|
||||
.alert-icon-todo {
|
||||
fill: #478be6;
|
||||
}
|
||||
|
||||
.alert-icon-success,
|
||||
.alert-icon-done {
|
||||
fill: #57ab5a;
|
||||
}
|
||||
|
||||
.alert-icon-question,
|
||||
.alert-icon-help,
|
||||
.alert-icon-faq {
|
||||
fill: #c69026;
|
||||
}
|
||||
|
||||
.alert-icon-failure,
|
||||
.alert-icon-fail,
|
||||
.alert-icon-missing {
|
||||
fill: #e5534b;
|
||||
}
|
||||
|
||||
.alert-icon-danger,
|
||||
.alert-icon-error {
|
||||
fill: #e5534b;
|
||||
}
|
||||
|
||||
.alert-icon-bug {
|
||||
fill: #e5534b;
|
||||
}
|
||||
|
||||
.alert-icon-example {
|
||||
fill: #986ee2;
|
||||
}
|
||||
|
||||
.alert-icon-quote,
|
||||
.alert-icon-cite {
|
||||
fill: #9ca3af;
|
||||
}
|
||||
|
||||
/* ==================== 代码块 ==================== */
|
||||
pre.code__pre,
|
||||
.hljs.code__pre {
|
||||
font-size: 90%;
|
||||
overflow-x: auto;
|
||||
border-radius: 8px;
|
||||
padding: 0 !important;
|
||||
line-height: 1.5;
|
||||
margin: 10px 8px;
|
||||
}
|
||||
|
||||
/* ==================== 图片 ==================== */
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin: 0.1em auto 0.5em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ==================== 列表 ==================== */
|
||||
ol {
|
||||
padding-left: 1em;
|
||||
margin-left: 0;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: circle;
|
||||
padding-left: 1em;
|
||||
margin-left: 0;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
li {
|
||||
display: block;
|
||||
margin: 0.2em 8px;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* ==================== 脚注 ==================== */
|
||||
/* footnotes 在 buildFootnotes() 中渲染为 <p> 标签 */
|
||||
p.footnotes {
|
||||
margin: 0.5em 8px;
|
||||
font-size: 80%;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* ==================== 图表 ==================== */
|
||||
figure {
|
||||
margin: 1.5em 8px;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
figcaption,
|
||||
.md-figcaption {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
/* ==================== 分隔线 ==================== */
|
||||
hr {
|
||||
border-style: solid;
|
||||
border-width: 2px 0 0;
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
-webkit-transform-origin: 0 0;
|
||||
-webkit-transform: scale(1, 0.5);
|
||||
transform-origin: 0 0;
|
||||
transform: scale(1, 0.5);
|
||||
height: 0.4em;
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
/* ==================== 行内代码 ==================== */
|
||||
code {
|
||||
font-size: 90%;
|
||||
color: #d14;
|
||||
background: rgba(27, 31, 35, 0.05);
|
||||
padding: 3px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 代码块内的 code 标签需要特殊处理(覆盖行内 code 样式) */
|
||||
pre.code__pre > code,
|
||||
.hljs.code__pre > code {
|
||||
display: -webkit-box;
|
||||
padding: 0.5em 1em 1em;
|
||||
overflow-x: auto;
|
||||
text-indent: 0;
|
||||
color: inherit;
|
||||
background: none;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ==================== 强调 ==================== */
|
||||
em {
|
||||
font-style: italic;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* ==================== 链接 ==================== */
|
||||
a {
|
||||
color: #576b95;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ==================== 粗体 ==================== */
|
||||
strong {
|
||||
color: var(--md-primary-color);
|
||||
font-weight: bold;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* ==================== 表格 ==================== */
|
||||
table {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
thead {
|
||||
font-weight: bold;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
th {
|
||||
border: 1px solid #dfdfdf;
|
||||
padding: 0.25em 0.5em;
|
||||
color: hsl(var(--foreground));
|
||||
word-break: keep-all;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
td {
|
||||
border: 1px solid #dfdfdf;
|
||||
padding: 0.25em 0.5em;
|
||||
color: hsl(var(--foreground));
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
/* ==================== KaTeX 公式 ==================== */
|
||||
.katex-inline {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.katex-block {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 0.5em 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ==================== 标记高亮 ==================== */
|
||||
.markup-highlight {
|
||||
background-color: var(--md-primary-color);
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.markup-underline {
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--md-primary-color);
|
||||
}
|
||||
|
||||
.markup-wavyline {
|
||||
text-decoration: underline wavy;
|
||||
text-decoration-color: var(--md-primary-color);
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* MD 优雅主题 (@brzhang)
|
||||
* 在默认主题基础上添加优雅的视觉效果
|
||||
*/
|
||||
|
||||
/* ==================== 标题样式 ==================== */
|
||||
h1 {
|
||||
padding: 0.5em 1em;
|
||||
border-bottom: 2px solid var(--md-primary-color);
|
||||
font-size: calc(var(--md-font-size) * 1.4);
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h2 {
|
||||
padding: 0.3em 1em;
|
||||
border-radius: 8px;
|
||||
font-size: calc(var(--md-font-size) * 1.3);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h3 {
|
||||
padding-left: 12px;
|
||||
font-size: calc(var(--md-font-size) * 1.2);
|
||||
border-left: 4px solid var(--md-primary-color);
|
||||
border-bottom: 1px dashed var(--md-primary-color);
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: calc(var(--md-font-size) * 1.1);
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: var(--md-font-size);
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: var(--md-font-size);
|
||||
}
|
||||
|
||||
/* ==================== 引用块 ==================== */
|
||||
blockquote {
|
||||
font-style: italic;
|
||||
padding: 1em 1em 1em 2em;
|
||||
border-left: 4px solid var(--md-primary-color);
|
||||
border-radius: 6px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.markdown-alert {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ==================== 代码块 ==================== */
|
||||
pre.code__pre,
|
||||
.hljs.code__pre {
|
||||
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
pre.code__pre > code,
|
||||
.hljs.code__pre > code {
|
||||
font-family:
|
||||
'Fira Code',
|
||||
Menlo,
|
||||
Operator Mono,
|
||||
Consolas,
|
||||
Monaco,
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* ==================== 图片 ==================== */
|
||||
img {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
figcaption,
|
||||
.md-figcaption {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
/* ==================== 列表 ==================== */
|
||||
ol {
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.5em 8px;
|
||||
}
|
||||
|
||||
/* ==================== 分隔线 ==================== */
|
||||
hr {
|
||||
height: 1px;
|
||||
border: none;
|
||||
margin: 2em 0;
|
||||
background: linear-gradient(to right, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0));
|
||||
}
|
||||
|
||||
/* ==================== 表格 ==================== */
|
||||
table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
border-radius: 8px;
|
||||
margin: 1em 8px;
|
||||
color: hsl(var(--foreground));
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
thead {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
|
||||
/* ==================== 强调 ==================== */
|
||||
em {
|
||||
font-style: italic;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* ==================== 链接 ==================== */
|
||||
a {
|
||||
color: #576b95;
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* MD 简洁主题 (@okooo5km)
|
||||
* 简洁现代的设计风格
|
||||
*/
|
||||
|
||||
/* ==================== 标题样式 ==================== */
|
||||
h1 {
|
||||
padding: 0.5em 1em;
|
||||
font-size: calc(var(--md-font-size) * 1.4);
|
||||
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
h2 {
|
||||
padding: 0.3em 1.2em;
|
||||
font-size: calc(var(--md-font-size) * 1.3);
|
||||
border-radius: 8px 24px 8px 24px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
h3 {
|
||||
padding-left: 12px;
|
||||
font-size: calc(var(--md-font-size) * 1.2);
|
||||
border-radius: 6px;
|
||||
line-height: 2.4em;
|
||||
border-left: 4px solid var(--md-primary-color);
|
||||
border-right: 1px solid color-mix(in srgb, var(--md-primary-color) 10%, transparent);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--md-primary-color) 10%, transparent);
|
||||
border-top: 1px solid color-mix(in srgb, var(--md-primary-color) 10%, transparent);
|
||||
background: color-mix(in srgb, var(--md-primary-color) 8%, transparent);
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: calc(var(--md-font-size) * 1.1);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: var(--md-font-size);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: var(--md-font-size);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ==================== 引用块 ==================== */
|
||||
blockquote {
|
||||
font-style: italic;
|
||||
padding: 1em 1em 1em 2em;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
border-bottom: 0.2px solid rgba(0, 0, 0, 0.04);
|
||||
border-top: 0.2px solid rgba(0, 0, 0, 0.04);
|
||||
border-right: 0.2px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* GFM Alert 样式覆盖 */
|
||||
.markdown-alert-note,
|
||||
.markdown-alert-tip,
|
||||
.markdown-alert-info,
|
||||
.markdown-alert-important,
|
||||
.markdown-alert-warning,
|
||||
.markdown-alert-caution {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ==================== 代码块 ==================== */
|
||||
pre.code__pre,
|
||||
.hljs.code__pre {
|
||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
pre.code__pre > code,
|
||||
.hljs.code__pre > code {
|
||||
font-family:
|
||||
'Fira Code',
|
||||
Menlo,
|
||||
Operator Mono,
|
||||
Consolas,
|
||||
Monaco,
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* ==================== 图片 ==================== */
|
||||
img {
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
figcaption,
|
||||
.md-figcaption {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
/* ==================== 列表 ==================== */
|
||||
ol {
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.5em 8px;
|
||||
}
|
||||
|
||||
/* ==================== 分隔线 ==================== */
|
||||
hr {
|
||||
height: 1px;
|
||||
border: none;
|
||||
margin: 2em 0;
|
||||
background: linear-gradient(to right, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0));
|
||||
}
|
||||
|
||||
/* ==================== 强调 ==================== */
|
||||
em {
|
||||
font-style: italic;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* ==================== 链接 ==================== */
|
||||
a {
|
||||
color: #576b95;
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import type { LanguageFn } from 'highlight.js'
|
||||
import bash from 'highlight.js/lib/languages/bash'
|
||||
import c from 'highlight.js/lib/languages/c'
|
||||
import cpp from 'highlight.js/lib/languages/cpp'
|
||||
import csharp from 'highlight.js/lib/languages/csharp'
|
||||
import css from 'highlight.js/lib/languages/css'
|
||||
import diff from 'highlight.js/lib/languages/diff'
|
||||
import go from 'highlight.js/lib/languages/go'
|
||||
import graphql from 'highlight.js/lib/languages/graphql'
|
||||
import ini from 'highlight.js/lib/languages/ini'
|
||||
import java from 'highlight.js/lib/languages/java'
|
||||
import javascript from 'highlight.js/lib/languages/javascript'
|
||||
import json from 'highlight.js/lib/languages/json'
|
||||
import kotlin from 'highlight.js/lib/languages/kotlin'
|
||||
import less from 'highlight.js/lib/languages/less'
|
||||
import lua from 'highlight.js/lib/languages/lua'
|
||||
import makefile from 'highlight.js/lib/languages/makefile'
|
||||
import markdown from 'highlight.js/lib/languages/markdown'
|
||||
import objectivec from 'highlight.js/lib/languages/objectivec'
|
||||
import perl from 'highlight.js/lib/languages/perl'
|
||||
import php from 'highlight.js/lib/languages/php'
|
||||
import phpTemplate from 'highlight.js/lib/languages/php-template'
|
||||
import plaintext from 'highlight.js/lib/languages/plaintext'
|
||||
import python from 'highlight.js/lib/languages/python'
|
||||
import pythonRepl from 'highlight.js/lib/languages/python-repl'
|
||||
import r from 'highlight.js/lib/languages/r'
|
||||
import ruby from 'highlight.js/lib/languages/ruby'
|
||||
import rust from 'highlight.js/lib/languages/rust'
|
||||
import scss from 'highlight.js/lib/languages/scss'
|
||||
import shell from 'highlight.js/lib/languages/shell'
|
||||
import sql from 'highlight.js/lib/languages/sql'
|
||||
import swift from 'highlight.js/lib/languages/swift'
|
||||
import typescript from 'highlight.js/lib/languages/typescript'
|
||||
import vbnet from 'highlight.js/lib/languages/vbnet'
|
||||
import wasm from 'highlight.js/lib/languages/wasm'
|
||||
import xml from 'highlight.js/lib/languages/xml'
|
||||
import yaml from 'highlight.js/lib/languages/yaml'
|
||||
|
||||
export const COMMON_LANGUAGES: Record<string, LanguageFn> = {
|
||||
bash,
|
||||
c,
|
||||
cpp,
|
||||
csharp,
|
||||
css,
|
||||
diff,
|
||||
go,
|
||||
graphql,
|
||||
ini,
|
||||
java,
|
||||
javascript,
|
||||
json,
|
||||
kotlin,
|
||||
less,
|
||||
lua,
|
||||
makefile,
|
||||
markdown,
|
||||
objectivec,
|
||||
perl,
|
||||
php,
|
||||
'php-template': phpTemplate,
|
||||
plaintext,
|
||||
python,
|
||||
'python-repl': pythonRepl,
|
||||
r,
|
||||
ruby,
|
||||
rust,
|
||||
scss,
|
||||
shell,
|
||||
sql,
|
||||
swift,
|
||||
typescript,
|
||||
vbnet,
|
||||
wasm,
|
||||
xml,
|
||||
yaml,
|
||||
}
|
||||
|
||||
// highlight.js CDN 配置
|
||||
const HLJS_VERSION = `11.11.1`
|
||||
const HLJS_CDN_BASE = `https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/npm/highlightjs/${HLJS_VERSION}`
|
||||
|
||||
// 缓存正在加载的语言
|
||||
const loadingLanguages = new Map<string, Promise<void>>()
|
||||
|
||||
/**
|
||||
* 生成语言包的 CDN URL
|
||||
*/
|
||||
function grammarUrlFor(language: string): string {
|
||||
return `${HLJS_CDN_BASE}/es/languages/${language}.min.js`
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态加载并注册语言
|
||||
* @param language 语言名称
|
||||
* @param hljs highlight.js 实例
|
||||
*/
|
||||
export async function loadAndRegisterLanguage(language: string, hljs: any): Promise<void> {
|
||||
// 如果已经注册,直接返回
|
||||
if (hljs.getLanguage(language)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果正在加载,等待加载完成
|
||||
if (loadingLanguages.has(language)) {
|
||||
await loadingLanguages.get(language)
|
||||
return
|
||||
}
|
||||
|
||||
// 开始加载
|
||||
const loadPromise = (async () => {
|
||||
try {
|
||||
const module = await import(/* @vite-ignore */ grammarUrlFor(language))
|
||||
hljs.registerLanguage(language, module.default)
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(`Failed to load language: ${language}`, error)
|
||||
throw error
|
||||
}
|
||||
finally {
|
||||
loadingLanguages.delete(language)
|
||||
}
|
||||
})()
|
||||
|
||||
loadingLanguages.set(language, loadPromise)
|
||||
await loadPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化高亮后的代码,处理空格和制表符
|
||||
*/
|
||||
function formatHighlightedCode(html: string, preserveNewlines = false): string {
|
||||
let formatted = html
|
||||
// 将 span 之间的空格移到 span 内部
|
||||
formatted = formatted.replace(/(<span[^>]*>[^<]*<\/span>)(\s+)(<span[^>]*>[^<]*<\/span>)/g, (_: string, span1: string, spaces: string, span2: string) => span1 + span2.replace(/^(<span[^>]*>)/, `$1${spaces}`))
|
||||
formatted = formatted.replace(/(\s+)(<span[^>]*>)/g, (_: string, spaces: string, span: string) => span.replace(/^(<span[^>]*>)/, `$1${spaces}`))
|
||||
// 替换制表符为4个空格
|
||||
formatted = formatted.replace(/\t/g, ` `)
|
||||
|
||||
if (preserveNewlines) {
|
||||
// 替换换行符为 <br/>,并将空格转换为
|
||||
formatted = formatted.replace(/\r\n/g, `<br/>`).replace(/\n/g, `<br/>`).replace(/(>[^<]+)|(^[^<]+)/g, (str: string) => str.replace(/\s/g, ` `))
|
||||
}
|
||||
else {
|
||||
// 只将空格转换为
|
||||
formatted = formatted.replace(/(>[^<]+)|(^[^<]+)/g, (str: string) => str.replace(/\s/g, ` `))
|
||||
}
|
||||
|
||||
return formatted
|
||||
}
|
||||
|
||||
/**
|
||||
* 高亮代码并格式化(支持行号)
|
||||
* @param text 原始代码文本
|
||||
* @param language 语言名称
|
||||
* @param hljs highlight.js 实例
|
||||
* @param showLineNumber 是否显示行号
|
||||
* @returns 格式化后的 HTML
|
||||
*/
|
||||
export function highlightAndFormatCode(text: string, language: string, hljs: any, showLineNumber: boolean): string {
|
||||
let highlighted = ``
|
||||
|
||||
if (showLineNumber) {
|
||||
const rawLines = text.replace(/\r\n/g, `\n`).split(`\n`)
|
||||
|
||||
const highlightedLines = rawLines.map((lineRaw) => {
|
||||
const lineHtml = hljs.highlight(lineRaw, { language }).value
|
||||
const formatted = formatHighlightedCode(lineHtml, false)
|
||||
return formatted === `` ? ` ` : formatted
|
||||
})
|
||||
|
||||
const lineNumbersHtml = highlightedLines.map((_, idx) => `<section style="padding:0 10px 0 0;line-height:1.75">${idx + 1}</section>`).join(``)
|
||||
const codeInnerHtml = highlightedLines.join(`<br/>`)
|
||||
const codeLinesHtml = `<div style="white-space:pre;min-width:max-content;line-height:1.75">${codeInnerHtml}</div>`
|
||||
const lineNumberColumnStyles = `text-align:right;padding:8px 0;border-right:1px solid rgba(0,0,0,0.04);user-select:none;background:var(--code-bg,transparent);`
|
||||
|
||||
highlighted = `
|
||||
<section style="display:flex;align-items:flex-start;overflow-x:hidden;overflow-y:auto;width:100%;max-width:100%;padding:0;box-sizing:border-box">
|
||||
<section class="line-numbers" style="${lineNumberColumnStyles}">${lineNumbersHtml}</section>
|
||||
<section class="code-scroll" style="flex:1 1 auto;overflow-x:auto;overflow-y:visible;padding:8px;min-width:0;box-sizing:border-box">${codeLinesHtml}</section>
|
||||
</section>
|
||||
`
|
||||
}
|
||||
else {
|
||||
const rawHighlighted = hljs.highlight(text, { language }).value
|
||||
highlighted = formatHighlightedCode(rawHighlighted, true)
|
||||
}
|
||||
|
||||
return highlighted
|
||||
}
|
||||
|
||||
export function highlightCodeBlock(codeBlock: Element, language: string, hljs: any): void {
|
||||
const rawCode = codeBlock.getAttribute(`data-raw-code`)
|
||||
const showLineNumber = codeBlock.getAttribute(`data-show-line-number`) === `true`
|
||||
|
||||
if (!rawCode)
|
||||
return
|
||||
|
||||
const text = rawCode.replace(/"/g, `"`)
|
||||
|
||||
const highlighted = highlightAndFormatCode(text, language, hljs, showLineNumber)
|
||||
|
||||
codeBlock.innerHTML = highlighted
|
||||
codeBlock.removeAttribute(`data-language-pending`)
|
||||
codeBlock.removeAttribute(`data-raw-code`)
|
||||
codeBlock.removeAttribute(`data-show-line-number`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 高亮 DOM 中待处理的代码块
|
||||
* 查找带有 data-language-pending 属性的代码块,动态加载语言后重新高亮
|
||||
* @param hljs highlight.js 实例
|
||||
* @param container 容器元素(可选,默认为 document)
|
||||
*/
|
||||
export function highlightPendingBlocks(hljs: any, container: Document | Element = document): void {
|
||||
const pendingBlocks = container.querySelectorAll(`code[data-language-pending]`)
|
||||
|
||||
pendingBlocks.forEach((codeBlock) => {
|
||||
const language = codeBlock.getAttribute(`data-language-pending`)
|
||||
if (!language)
|
||||
return
|
||||
|
||||
if (hljs.getLanguage(language)) {
|
||||
// 语言已加载,直接高亮
|
||||
highlightCodeBlock(codeBlock, language, hljs)
|
||||
}
|
||||
else {
|
||||
// 动态加载语言后重新高亮
|
||||
loadAndRegisterLanguage(language, hljs).then(() => {
|
||||
highlightCodeBlock(codeBlock, language, hljs)
|
||||
}).catch(() => {
|
||||
// 加载失败,移除标记
|
||||
codeBlock.removeAttribute(`data-language-pending`)
|
||||
codeBlock.removeAttribute(`data-raw-code`)
|
||||
codeBlock.removeAttribute(`data-show-line-number`)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { execSync, spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
const WECHAT_URL = 'https://mp.weixin.qq.com/';
|
||||
const SESSION = 'wechat-post';
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function ab(cmd: string, json = false): string {
|
||||
const fullCmd = `agent-browser --session ${SESSION} ${cmd}${json ? ' --json' : ''}`;
|
||||
console.log(`[ab] ${fullCmd}`);
|
||||
try {
|
||||
const result = execSync(fullCmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
return result.trim();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; message?: string };
|
||||
console.error(`[ab] Error: ${err.stderr || err.message}`);
|
||||
return err.stdout || '';
|
||||
}
|
||||
}
|
||||
|
||||
function abRaw(args: string[]): { success: boolean; output: string } {
|
||||
const result = spawnSync('agent-browser', ['--session', SESSION, ...args], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
return {
|
||||
success: result.status === 0,
|
||||
output: result.stdout || result.stderr || ''
|
||||
};
|
||||
}
|
||||
|
||||
interface SnapshotElement {
|
||||
ref: string;
|
||||
role: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function parseSnapshot(output: string): SnapshotElement[] {
|
||||
const elements: SnapshotElement[] = [];
|
||||
const refPattern = /\[ref=(@?\w+)\]/g;
|
||||
const lines = output.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/\[ref=([@\w]+)\]/);
|
||||
if (match) {
|
||||
const ref = match[1].startsWith('@') ? match[1] : `@${match[1]}`;
|
||||
const roleMatch = line.match(/^-\s+(\w+)/);
|
||||
const nameMatch = line.match(/"([^"]+)"/);
|
||||
elements.push({
|
||||
ref,
|
||||
role: roleMatch?.[1] || 'unknown',
|
||||
name: nameMatch?.[1] || ''
|
||||
});
|
||||
}
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
function findElementByText(snapshot: string, text: string): string | null {
|
||||
const lines = snapshot.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.includes(`"${text}"`) || line.includes(text)) {
|
||||
const match = line.match(/\[ref=([@\w]+)\]/);
|
||||
if (match) {
|
||||
return match[1].startsWith('@') ? match[1] : `@${match[1]}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findElementBySelector(snapshot: string, selector: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
interface WeChatOptions {
|
||||
title: string;
|
||||
content: string;
|
||||
images: string[];
|
||||
submit?: boolean;
|
||||
keepOpen?: boolean;
|
||||
}
|
||||
|
||||
async function postToWeChat(options: WeChatOptions): Promise<void> {
|
||||
const { title, content, images, submit = false, keepOpen = true } = options;
|
||||
|
||||
if (title.length > 20) throw new Error(`Title too long: ${title.length} chars (max 20)`);
|
||||
if (content.length > 1000) throw new Error(`Content too long: ${content.length} chars (max 1000)`);
|
||||
if (images.length === 0) throw new Error('At least one image is required');
|
||||
|
||||
const absoluteImages = images.map(p => path.isAbsolute(p) ? p : path.resolve(process.cwd(), p));
|
||||
for (const img of absoluteImages) {
|
||||
if (!fs.existsSync(img)) throw new Error(`Image not found: ${img}`);
|
||||
}
|
||||
|
||||
console.log('[wechat] Opening WeChat Official Account...');
|
||||
ab(`open ${WECHAT_URL} --headed`);
|
||||
await sleep(5000);
|
||||
|
||||
console.log('[wechat] Checking login status...');
|
||||
let url = ab('get url');
|
||||
console.log(`[wechat] Current URL: ${url}`);
|
||||
|
||||
const waitForLogin = async (timeoutMs = 120_000): Promise<boolean> => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
url = ab('get url');
|
||||
if (url.includes('/cgi-bin/home')) return true;
|
||||
console.log('[wechat] Waiting for login...');
|
||||
await sleep(3000);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (!url.includes('/cgi-bin/home')) {
|
||||
console.log('[wechat] Not logged in. Please scan QR code...');
|
||||
const loggedIn = await waitForLogin();
|
||||
if (!loggedIn) throw new Error('Login timeout');
|
||||
}
|
||||
console.log('[wechat] Logged in.');
|
||||
await sleep(2000);
|
||||
|
||||
console.log('[wechat] Getting page snapshot...');
|
||||
let snapshot = ab('snapshot');
|
||||
console.log(snapshot);
|
||||
|
||||
console.log('[wechat] Looking for "图文" menu...');
|
||||
const tuWenRef = findElementByText(snapshot, '图文');
|
||||
|
||||
if (!tuWenRef) {
|
||||
console.log('[wechat] Using eval to find and click menu...');
|
||||
ab(`eval "document.querySelectorAll('.new-creation__menu .new-creation__menu-item')[2].click()"`);
|
||||
} else {
|
||||
console.log(`[wechat] Clicking menu ref: ${tuWenRef}`);
|
||||
ab(`click ${tuWenRef}`);
|
||||
}
|
||||
|
||||
await sleep(4000);
|
||||
|
||||
console.log('[wechat] Checking for new tab...');
|
||||
const tabsOutput = ab('tab');
|
||||
console.log(`[wechat] Tabs: ${tabsOutput}`);
|
||||
|
||||
const tabLines = tabsOutput.split('\n');
|
||||
const editorTabLine = tabLines.find(l => l.includes('appmsg') || (!l.includes('cgi-bin/home') && l.includes('mp.weixin.qq.com')));
|
||||
|
||||
if (tabLines.length > 1) {
|
||||
const tabMatch = tabsOutput.match(/\[(\d+)\].*(?:appmsg|edit)/i);
|
||||
if (tabMatch) {
|
||||
console.log(`[wechat] Switching to editor tab ${tabMatch[1]}...`);
|
||||
ab(`tab ${tabMatch[1]}`);
|
||||
} else {
|
||||
const lastTabMatch = tabsOutput.match(/\[(\d+)\]/g);
|
||||
if (lastTabMatch && lastTabMatch.length > 1) {
|
||||
const lastTab = lastTabMatch[lastTabMatch.length - 1].match(/\d+/)?.[0];
|
||||
if (lastTab) {
|
||||
console.log(`[wechat] Switching to last tab ${lastTab}...`);
|
||||
ab(`tab ${lastTab}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(3000);
|
||||
|
||||
url = ab('get url');
|
||||
console.log(`[wechat] Editor URL: ${url}`);
|
||||
|
||||
console.log('[wechat] Getting editor snapshot...');
|
||||
snapshot = ab('snapshot');
|
||||
console.log(snapshot.substring(0, 2000));
|
||||
|
||||
console.log('[wechat] Uploading images...');
|
||||
const fileInputSelector = '.js_upload_btn_container input[type=file]';
|
||||
|
||||
ab(`eval "document.querySelector('${fileInputSelector}').style.display = 'block'"`);
|
||||
await sleep(500);
|
||||
|
||||
const uploadResult = abRaw(['upload', `"${fileInputSelector}"`, ...absoluteImages]);
|
||||
console.log(`[wechat] Upload result: ${uploadResult.output}`);
|
||||
|
||||
if (!uploadResult.success) {
|
||||
console.log('[wechat] Using alternative upload method...');
|
||||
for (const img of absoluteImages) {
|
||||
console.log(`[wechat] Uploading: ${img}`);
|
||||
ab(`eval "
|
||||
const input = document.querySelector('${fileInputSelector}');
|
||||
if (input) {
|
||||
const dt = new DataTransfer();
|
||||
fetch('file://${img}').then(r => r.blob()).then(b => {
|
||||
const file = new File([b], '${path.basename(img)}', { type: 'image/png' });
|
||||
dt.items.add(file);
|
||||
input.files = dt.files;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
"`);
|
||||
await sleep(2000);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[wechat] Waiting for uploads to complete...');
|
||||
await sleep(10000);
|
||||
|
||||
console.log('[wechat] Filling title...');
|
||||
snapshot = ab('snapshot -i');
|
||||
const titleRef = findElementByText(snapshot, 'title') || findElementByText(snapshot, '标题');
|
||||
|
||||
if (titleRef) {
|
||||
ab(`fill ${titleRef} "${title.replace(/"/g, '\\"')}"`);
|
||||
} else {
|
||||
ab(`eval "const t = document.querySelector('#title'); if(t) { t.value = '${title.replace(/'/g, "\\'")}'; t.dispatchEvent(new Event('input', {bubbles: true})); }"`);
|
||||
}
|
||||
await sleep(500);
|
||||
|
||||
console.log('[wechat] Clicking on content editor...');
|
||||
const editorRef = findElementByText(snapshot, 'js_pmEditorArea') || findElementByText(snapshot, 'textbox');
|
||||
|
||||
if (editorRef) {
|
||||
ab(`click ${editorRef}`);
|
||||
} else {
|
||||
ab(`eval "document.querySelector('.js_pmEditorArea')?.click()"`);
|
||||
}
|
||||
await sleep(500);
|
||||
|
||||
console.log('[wechat] Typing content...');
|
||||
const lines = content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.length > 0) {
|
||||
const escapedLine = line.replace(/"/g, '\\"').replace(/'/g, "\\'");
|
||||
ab(`eval "document.execCommand('insertText', false, '${escapedLine}')"`);
|
||||
}
|
||||
if (i < lines.length - 1) {
|
||||
ab('press Enter');
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
console.log('[wechat] Content typed.');
|
||||
await sleep(1000);
|
||||
|
||||
if (submit) {
|
||||
console.log('[wechat] Saving as draft...');
|
||||
const submitRef = findElementByText(snapshot, 'js_submit') || findElementByText(snapshot, '保存');
|
||||
if (submitRef) {
|
||||
ab(`click ${submitRef}`);
|
||||
} else {
|
||||
ab(`eval "document.querySelector('#js_submit')?.click()"`);
|
||||
}
|
||||
await sleep(3000);
|
||||
console.log('[wechat] Draft saved!');
|
||||
} else {
|
||||
console.log('[wechat] Article composed (preview mode). Add --submit to save as draft.');
|
||||
}
|
||||
|
||||
if (!keepOpen) {
|
||||
console.log('[wechat] Closing browser...');
|
||||
ab('close');
|
||||
} else {
|
||||
console.log('[wechat] Done. Browser window left open.');
|
||||
}
|
||||
}
|
||||
|
||||
function printUsage(): never {
|
||||
console.log(`Post to WeChat Official Account using agent-browser
|
||||
|
||||
Usage:
|
||||
npx -y bun wechat-agent-browser.ts [options]
|
||||
|
||||
Options:
|
||||
--title <text> Article title (max 20 chars, required)
|
||||
--content <text> Article content (max 1000 chars, required)
|
||||
--image <path> Add image (can be repeated, 1+ images, required)
|
||||
--submit Save as draft (default: preview only)
|
||||
--close Close browser after operation (default: keep open)
|
||||
--help Show this help
|
||||
|
||||
Examples:
|
||||
npx -y bun wechat-agent-browser.ts --title "测试" --content "内容" --image ./photo.png
|
||||
npx -y bun wechat-agent-browser.ts --title "测试" --content "内容" --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 keepOpen = true;
|
||||
let title: string | undefined;
|
||||
let content: string | undefined;
|
||||
|
||||
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 === '--title' && args[i + 1]) {
|
||||
title = args[++i];
|
||||
} else if (arg === '--content' && args[i + 1]) {
|
||||
content = args[++i];
|
||||
} else if (arg === '--submit') {
|
||||
submit = true;
|
||||
} else if (arg === '--close') {
|
||||
keepOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
console.error('Error: --title is required');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!content) {
|
||||
console.error('Error: --content is required');
|
||||
process.exit(1);
|
||||
}
|
||||
if (images.length === 0) {
|
||||
console.error('Error: At least one --image is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await postToWeChat({ title, content, images, submit, keepOpen });
|
||||
}
|
||||
|
||||
await main().catch((err) => {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,445 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import process from 'node:process';
|
||||
import { launchChrome, getPageSession, waitForNewTab, clickElement, typeText, evaluate, sleep, type ChromeSession, type CdpConnection } from './cdp.ts';
|
||||
|
||||
const WECHAT_URL = 'https://mp.weixin.qq.com/';
|
||||
|
||||
interface ImageInfo {
|
||||
placeholder: string;
|
||||
localPath: string;
|
||||
originalPath: string;
|
||||
}
|
||||
|
||||
interface ArticleOptions {
|
||||
title: string;
|
||||
content?: string;
|
||||
htmlFile?: string;
|
||||
markdownFile?: string;
|
||||
theme?: string;
|
||||
author?: string;
|
||||
summary?: string;
|
||||
images?: string[];
|
||||
contentImages?: ImageInfo[];
|
||||
submit?: boolean;
|
||||
profileDir?: string;
|
||||
}
|
||||
|
||||
async function waitForLogin(session: ChromeSession, timeoutMs = 120_000): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const url = await evaluate<string>(session, 'window.location.href');
|
||||
if (url.includes('/cgi-bin/home')) return true;
|
||||
await sleep(2000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function clickMenuByText(session: ChromeSession, text: string): Promise<void> {
|
||||
console.log(`[wechat] Clicking "${text}" menu...`);
|
||||
const posResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
(function() {
|
||||
const items = document.querySelectorAll('.new-creation__menu .new-creation__menu-item');
|
||||
for (const item of items) {
|
||||
const title = item.querySelector('.new-creation__menu-title');
|
||||
if (title && title.textContent?.trim() === '${text}') {
|
||||
item.scrollIntoView({ block: 'center' });
|
||||
const rect = item.getBoundingClientRect();
|
||||
return JSON.stringify({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 });
|
||||
}
|
||||
}
|
||||
return 'null';
|
||||
})()
|
||||
`,
|
||||
returnByValue: true,
|
||||
}, { sessionId: session.sessionId });
|
||||
|
||||
if (posResult.result.value === 'null') throw new Error(`Menu "${text}" not found`);
|
||||
const pos = JSON.parse(posResult.result.value);
|
||||
|
||||
await session.cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });
|
||||
await sleep(100);
|
||||
await session.cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });
|
||||
}
|
||||
|
||||
async function copyImageToClipboard(imagePath: string): Promise<void> {
|
||||
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) throw new Error(`Failed to copy image: ${imagePath}`);
|
||||
}
|
||||
|
||||
async function pasteInEditor(session: ChromeSession): Promise<void> {
|
||||
const modifiers = process.platform === 'darwin' ? 4 : 2;
|
||||
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId });
|
||||
await sleep(50);
|
||||
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId });
|
||||
}
|
||||
|
||||
async function copyHtmlFromBrowser(cdp: CdpConnection, htmlFilePath: string): Promise<void> {
|
||||
const absolutePath = path.isAbsolute(htmlFilePath) ? htmlFilePath : path.resolve(process.cwd(), htmlFilePath);
|
||||
const fileUrl = `file://${absolutePath}`;
|
||||
|
||||
console.log(`[wechat] Opening HTML file in new tab: ${fileUrl}`);
|
||||
|
||||
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: fileUrl });
|
||||
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId, flatten: true });
|
||||
|
||||
await cdp.send('Page.enable', {}, { sessionId });
|
||||
await cdp.send('Runtime.enable', {}, { sessionId });
|
||||
await sleep(2000);
|
||||
|
||||
console.log('[wechat] Selecting #output content...');
|
||||
await cdp.send<{ result: { value: unknown } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
(function() {
|
||||
const output = document.querySelector('#output') || document.body;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(output);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return true;
|
||||
})()
|
||||
`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
await sleep(300);
|
||||
|
||||
console.log('[wechat] Copying with system Cmd+C...');
|
||||
if (process.platform === 'darwin') {
|
||||
spawnSync('osascript', ['-e', 'tell application "System Events" to keystroke "c" using command down']);
|
||||
} else {
|
||||
spawnSync('xdotool', ['key', 'ctrl+c']);
|
||||
}
|
||||
await sleep(1000);
|
||||
|
||||
console.log('[wechat] Closing HTML tab...');
|
||||
await cdp.send('Target.closeTarget', { targetId });
|
||||
}
|
||||
|
||||
async function pasteFromClipboardInEditor(): Promise<void> {
|
||||
if (process.platform === 'darwin') {
|
||||
spawnSync('osascript', ['-e', 'tell application "System Events" to keystroke "v" using command down']);
|
||||
} else {
|
||||
spawnSync('xdotool', ['key', 'ctrl+v']);
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
async function parseMarkdownWithPlaceholders(markdownPath: string, theme?: string): Promise<{ title: string; author: string; summary: string; htmlPath: string; contentImages: ImageInfo[] }> {
|
||||
const scriptDir = path.dirname(new URL(import.meta.url).pathname);
|
||||
const mdToWechatScript = path.join(scriptDir, 'md-to-wechat.ts');
|
||||
const args = ['-y', 'bun', mdToWechatScript, markdownPath];
|
||||
if (theme) args.push('--theme', theme);
|
||||
|
||||
const result = spawnSync('npx', args, { stdio: ['inherit', 'pipe', 'pipe'] });
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr?.toString() || '';
|
||||
throw new Error(`Failed to parse markdown: ${stderr}`);
|
||||
}
|
||||
|
||||
const output = result.stdout.toString();
|
||||
return JSON.parse(output);
|
||||
}
|
||||
|
||||
function parseHtmlMeta(htmlPath: string): { title: string; author: string; summary: string } {
|
||||
const content = fs.readFileSync(htmlPath, 'utf-8');
|
||||
|
||||
let title = '';
|
||||
const titleMatch = content.match(/<title>([^<]+)<\/title>/i);
|
||||
if (titleMatch) title = titleMatch[1]!;
|
||||
|
||||
let author = '';
|
||||
const authorMatch = content.match(/<meta\s+name=["']author["']\s+content=["']([^"']+)["']/i);
|
||||
if (authorMatch) author = authorMatch[1]!;
|
||||
|
||||
let summary = '';
|
||||
const descMatch = content.match(/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i);
|
||||
if (descMatch) summary = descMatch[1]!;
|
||||
|
||||
if (!summary) {
|
||||
const firstPMatch = content.match(/<p[^>]*>([^<]+)<\/p>/i);
|
||||
if (firstPMatch) {
|
||||
const text = firstPMatch[1]!.replace(/<[^>]+>/g, '').trim();
|
||||
if (text.length > 20) {
|
||||
summary = text.length > 120 ? text.slice(0, 117) + '...' : text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { title, author, summary };
|
||||
}
|
||||
|
||||
async function selectAndReplacePlaceholder(session: ChromeSession, placeholder: string): Promise<boolean> {
|
||||
const result = await session.cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
(function() {
|
||||
const editor = document.querySelector('.ProseMirror');
|
||||
if (!editor) return false;
|
||||
|
||||
const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false);
|
||||
let node;
|
||||
|
||||
while ((node = walker.nextNode())) {
|
||||
const text = node.textContent || '';
|
||||
const idx = text.indexOf(${JSON.stringify(placeholder)});
|
||||
if (idx !== -1) {
|
||||
node.parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
|
||||
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: session.sessionId });
|
||||
|
||||
return result.result.value;
|
||||
}
|
||||
|
||||
async function pressDeleteKey(session: ChromeSession): Promise<void> {
|
||||
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId: session.sessionId });
|
||||
await sleep(50);
|
||||
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId: session.sessionId });
|
||||
}
|
||||
|
||||
export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
const { title, content, htmlFile, markdownFile, theme, author, summary, images = [], submit = false, profileDir } = options;
|
||||
let { contentImages = [] } = options;
|
||||
let effectiveTitle = title || '';
|
||||
let effectiveAuthor = author || '';
|
||||
let effectiveSummary = summary || '';
|
||||
let effectiveHtmlFile = htmlFile;
|
||||
|
||||
if (markdownFile) {
|
||||
console.log(`[wechat] Parsing markdown: ${markdownFile}`);
|
||||
const parsed = await parseMarkdownWithPlaceholders(markdownFile, theme);
|
||||
effectiveTitle = effectiveTitle || parsed.title;
|
||||
effectiveAuthor = effectiveAuthor || parsed.author;
|
||||
effectiveSummary = effectiveSummary || parsed.summary;
|
||||
effectiveHtmlFile = parsed.htmlPath;
|
||||
contentImages = parsed.contentImages;
|
||||
console.log(`[wechat] Title: ${effectiveTitle || '(empty)'}`);
|
||||
console.log(`[wechat] Author: ${effectiveAuthor || '(empty)'}`);
|
||||
console.log(`[wechat] Summary: ${effectiveSummary || '(empty)'}`);
|
||||
console.log(`[wechat] Found ${contentImages.length} images to insert`);
|
||||
} else if (htmlFile && fs.existsSync(htmlFile)) {
|
||||
console.log(`[wechat] Parsing HTML: ${htmlFile}`);
|
||||
const meta = parseHtmlMeta(htmlFile);
|
||||
effectiveTitle = effectiveTitle || meta.title;
|
||||
effectiveAuthor = effectiveAuthor || meta.author;
|
||||
effectiveSummary = effectiveSummary || meta.summary;
|
||||
effectiveHtmlFile = htmlFile;
|
||||
console.log(`[wechat] Title: ${effectiveTitle || '(empty)'}`);
|
||||
console.log(`[wechat] Author: ${effectiveAuthor || '(empty)'}`);
|
||||
console.log(`[wechat] Summary: ${effectiveSummary || '(empty)'}`);
|
||||
}
|
||||
|
||||
if (effectiveTitle && effectiveTitle.length > 64) throw new Error(`Title too long: ${effectiveTitle.length} chars (max 64)`);
|
||||
if (!content && !effectiveHtmlFile) throw new Error('Either --content, --html, or --markdown is required');
|
||||
|
||||
const { cdp, chrome } = await launchChrome(WECHAT_URL, profileDir);
|
||||
|
||||
try {
|
||||
console.log('[wechat] Waiting for page load...');
|
||||
await sleep(3000);
|
||||
|
||||
let session = await getPageSession(cdp, 'mp.weixin.qq.com');
|
||||
|
||||
const url = await evaluate<string>(session, 'window.location.href');
|
||||
if (!url.includes('/cgi-bin/home')) {
|
||||
console.log('[wechat] Not logged in. Please scan QR code...');
|
||||
const loggedIn = await waitForLogin(session);
|
||||
if (!loggedIn) throw new Error('Login timeout');
|
||||
}
|
||||
console.log('[wechat] Logged in.');
|
||||
await sleep(2000);
|
||||
|
||||
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
||||
const initialIds = new Set(targets.targetInfos.map(t => t.targetId));
|
||||
|
||||
await clickMenuByText(session, '文章');
|
||||
await sleep(3000);
|
||||
|
||||
const editorTargetId = await waitForNewTab(cdp, initialIds, 'mp.weixin.qq.com');
|
||||
console.log('[wechat] Editor tab opened.');
|
||||
|
||||
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: editorTargetId, flatten: true });
|
||||
session = { cdp, sessionId, targetId: editorTargetId };
|
||||
|
||||
await cdp.send('Page.enable', {}, { sessionId });
|
||||
await cdp.send('Runtime.enable', {}, { sessionId });
|
||||
await cdp.send('DOM.enable', {}, { sessionId });
|
||||
|
||||
await sleep(3000);
|
||||
|
||||
if (effectiveTitle) {
|
||||
console.log('[wechat] Filling title...');
|
||||
await evaluate(session, `document.querySelector('#title').value = ${JSON.stringify(effectiveTitle)}; document.querySelector('#title').dispatchEvent(new Event('input', { bubbles: true }));`);
|
||||
}
|
||||
|
||||
if (effectiveAuthor) {
|
||||
console.log('[wechat] Filling author...');
|
||||
await evaluate(session, `document.querySelector('#author').value = ${JSON.stringify(effectiveAuthor)}; document.querySelector('#author').dispatchEvent(new Event('input', { bubbles: true }));`);
|
||||
}
|
||||
|
||||
console.log('[wechat] Clicking on editor...');
|
||||
await clickElement(session, '.ProseMirror');
|
||||
await sleep(500);
|
||||
|
||||
if (effectiveHtmlFile && fs.existsSync(effectiveHtmlFile)) {
|
||||
console.log(`[wechat] Copying HTML content from: ${effectiveHtmlFile}`);
|
||||
await copyHtmlFromBrowser(cdp, effectiveHtmlFile);
|
||||
await sleep(500);
|
||||
console.log('[wechat] Pasting into editor...');
|
||||
await pasteFromClipboardInEditor();
|
||||
await sleep(3000);
|
||||
|
||||
if (contentImages.length > 0) {
|
||||
console.log(`[wechat] Inserting ${contentImages.length} images...`);
|
||||
for (let i = 0; i < contentImages.length; i++) {
|
||||
const img = contentImages[i]!;
|
||||
console.log(`[wechat] [${i + 1}/${contentImages.length}] Processing: ${img.placeholder}`);
|
||||
|
||||
const found = await selectAndReplacePlaceholder(session, img.placeholder);
|
||||
if (!found) {
|
||||
console.warn(`[wechat] Placeholder not found: ${img.placeholder}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await sleep(500);
|
||||
|
||||
console.log(`[wechat] Copying image: ${path.basename(img.localPath)}`);
|
||||
await copyImageToClipboard(img.localPath);
|
||||
await sleep(300);
|
||||
|
||||
console.log('[wechat] Deleting placeholder with Backspace...');
|
||||
await pressDeleteKey(session);
|
||||
await sleep(200);
|
||||
|
||||
console.log('[wechat] Pasting image...');
|
||||
await pasteFromClipboardInEditor();
|
||||
await sleep(3000);
|
||||
}
|
||||
console.log('[wechat] All images inserted.');
|
||||
}
|
||||
} else if (content) {
|
||||
for (const img of images) {
|
||||
if (fs.existsSync(img)) {
|
||||
console.log(`[wechat] Pasting image: ${img}`);
|
||||
await copyImageToClipboard(img);
|
||||
await sleep(500);
|
||||
await pasteInEditor(session);
|
||||
await sleep(2000);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[wechat] Typing content...');
|
||||
await typeText(session, content);
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
if (effectiveSummary) {
|
||||
console.log(`[wechat] Filling summary: ${effectiveSummary}`);
|
||||
await evaluate(session, `document.querySelector('#js_description').value = ${JSON.stringify(effectiveSummary)}; document.querySelector('#js_description').dispatchEvent(new Event('input', { bubbles: true }));`);
|
||||
}
|
||||
|
||||
console.log('[wechat] Saving as draft...');
|
||||
await evaluate(session, `document.querySelector('#js_submit button').click()`);
|
||||
await sleep(3000);
|
||||
|
||||
const saved = await evaluate<boolean>(session, `!!document.querySelector('.weui-desktop-toast')`);
|
||||
if (saved) {
|
||||
console.log('[wechat] Draft saved successfully!');
|
||||
} else {
|
||||
console.log('[wechat] Waiting for save confirmation...');
|
||||
await sleep(5000);
|
||||
}
|
||||
|
||||
console.log('[wechat] Done. Browser window left open.');
|
||||
} finally {
|
||||
cdp.close();
|
||||
}
|
||||
}
|
||||
|
||||
function printUsage(): never {
|
||||
console.log(`Post article to WeChat Official Account
|
||||
|
||||
Usage:
|
||||
npx -y bun wechat-article.ts [options]
|
||||
|
||||
Options:
|
||||
--title <text> Article title (auto-extracted from markdown)
|
||||
--content <text> Article content (use with --image)
|
||||
--html <path> HTML file to paste (alternative to --content)
|
||||
--markdown <path> Markdown file to convert and post (recommended)
|
||||
--theme <name> Theme for markdown (default, grace, simple)
|
||||
--author <name> Author name (default: 宝玉)
|
||||
--summary <text> Article summary
|
||||
--image <path> Content image, can repeat (only with --content)
|
||||
--submit Save as draft
|
||||
--profile <dir> Chrome profile directory
|
||||
|
||||
Examples:
|
||||
npx -y bun wechat-article.ts --markdown article.md
|
||||
npx -y bun wechat-article.ts --markdown article.md --theme grace --submit
|
||||
npx -y bun wechat-article.ts --title "标题" --content "内容" --image img.png
|
||||
npx -y bun wechat-article.ts --title "标题" --html article.html --submit
|
||||
|
||||
Markdown mode:
|
||||
Images in markdown are converted to placeholders. After pasting HTML,
|
||||
each placeholder is selected, scrolled into view, deleted, and replaced
|
||||
with the actual image via paste.
|
||||
`);
|
||||
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 title: string | undefined;
|
||||
let content: string | undefined;
|
||||
let htmlFile: string | undefined;
|
||||
let markdownFile: string | undefined;
|
||||
let theme: string | undefined;
|
||||
let author: string | undefined;
|
||||
let summary: 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 === '--content' && args[i + 1]) content = args[++i];
|
||||
else if (arg === '--html' && args[i + 1]) htmlFile = args[++i];
|
||||
else if (arg === '--markdown' && args[i + 1]) markdownFile = args[++i];
|
||||
else if (arg === '--theme' && args[i + 1]) theme = args[++i];
|
||||
else if (arg === '--author' && args[i + 1]) author = args[++i];
|
||||
else if (arg === '--summary' && args[i + 1]) summary = args[++i];
|
||||
else 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];
|
||||
}
|
||||
|
||||
if (!markdownFile && !htmlFile && !title) { console.error('Error: --title is required (or use --markdown/--html)'); process.exit(1); }
|
||||
if (!markdownFile && !htmlFile && !content) { console.error('Error: --content, --html, or --markdown is required'); process.exit(1); }
|
||||
|
||||
await postArticle({ title: title || '', content, htmlFile, markdownFile, theme, author, summary, images, submit, profileDir });
|
||||
}
|
||||
|
||||
await main().catch((err) => {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,717 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { mkdir, readdir } 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 WECHAT_URL = 'https://mp.weixin.qq.com/';
|
||||
|
||||
interface MarkdownMeta {
|
||||
title: string;
|
||||
author: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
function parseMarkdownFile(filePath: string): MarkdownMeta {
|
||||
const text = fs.readFileSync(filePath, 'utf-8');
|
||||
let title = '';
|
||||
let author = '';
|
||||
let content = '';
|
||||
|
||||
const fmMatch = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
if (fmMatch) {
|
||||
const fm = fmMatch[1]!;
|
||||
const titleMatch = fm.match(/^title:\s*(.+)$/m);
|
||||
if (titleMatch) title = titleMatch[1]!.trim().replace(/^["']|["']$/g, '');
|
||||
const authorMatch = fm.match(/^author:\s*(.+)$/m);
|
||||
if (authorMatch) author = authorMatch[1]!.trim().replace(/^["']|["']$/g, '');
|
||||
}
|
||||
|
||||
const bodyText = fmMatch ? text.slice(fmMatch[0].length) : text;
|
||||
|
||||
if (!title) {
|
||||
const h1Match = bodyText.match(/^#\s+(.+)$/m);
|
||||
if (h1Match) title = h1Match[1]!.trim();
|
||||
}
|
||||
|
||||
const lines = bodyText.split('\n');
|
||||
const paragraphs: string[] = [];
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (trimmed.startsWith('#')) continue;
|
||||
if (trimmed.startsWith('![')) continue;
|
||||
if (trimmed.startsWith('---')) continue;
|
||||
paragraphs.push(trimmed);
|
||||
if (paragraphs.join('\n').length > 1200) break;
|
||||
}
|
||||
content = paragraphs.join('\n');
|
||||
|
||||
return { title, author, content };
|
||||
}
|
||||
|
||||
function compressTitle(title: string, maxLen = 20): string {
|
||||
if (title.length <= maxLen) return title;
|
||||
|
||||
const prefixes = ['如何', '为什么', '什么是', '怎样', '怎么', '关于'];
|
||||
let t = title;
|
||||
for (const p of prefixes) {
|
||||
if (t.startsWith(p) && t.length > maxLen) {
|
||||
t = t.slice(p.length);
|
||||
if (t.length <= maxLen) return t;
|
||||
}
|
||||
}
|
||||
|
||||
const fillers = ['的', '了', '在', '是', '和', '与', '以及', '或者', '或', '还是', '而且', '并且', '但是', '但', '因为', '所以', '如果', '那么', '虽然', '不过', '然而', '——', '…'];
|
||||
for (const f of fillers) {
|
||||
if (t.length <= maxLen) break;
|
||||
t = t.replace(new RegExp(f, 'g'), '');
|
||||
}
|
||||
|
||||
if (t.length > maxLen) t = t.slice(0, maxLen);
|
||||
|
||||
return t;
|
||||
}
|
||||
|
||||
function compressContent(content: string, maxLen = 1000): string {
|
||||
if (content.length <= maxLen) return content;
|
||||
|
||||
const lines = content.split('\n');
|
||||
const result: string[] = [];
|
||||
let len = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (len + line.length + 1 > maxLen) {
|
||||
const remaining = maxLen - len - 1;
|
||||
if (remaining > 20) result.push(line.slice(0, remaining - 3) + '...');
|
||||
break;
|
||||
}
|
||||
result.push(line);
|
||||
len += line.length + 1;
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
async function loadImagesFromDir(dir: string): Promise<string[]> {
|
||||
const entries = await readdir(dir);
|
||||
const images = entries
|
||||
.filter(f => /\.(png|jpg|jpeg|gif|webp)$/i.test(f))
|
||||
.sort()
|
||||
.map(f => path.join(dir, f));
|
||||
return images;
|
||||
}
|
||||
|
||||
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.WECHAT_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, 'wechat-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 WeChatBrowserOptions {
|
||||
title?: string;
|
||||
content?: string;
|
||||
images?: string[];
|
||||
imagesDir?: string;
|
||||
markdownFile?: string;
|
||||
submit?: boolean;
|
||||
timeoutMs?: number;
|
||||
profileDir?: string;
|
||||
chromePath?: string;
|
||||
}
|
||||
|
||||
export async function postToWeChat(options: WeChatBrowserOptions): Promise<void> {
|
||||
const { submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;
|
||||
|
||||
let title = options.title || '';
|
||||
let content = options.content || '';
|
||||
let images = options.images || [];
|
||||
|
||||
if (options.markdownFile) {
|
||||
const absPath = path.isAbsolute(options.markdownFile) ? options.markdownFile : path.resolve(process.cwd(), options.markdownFile);
|
||||
if (!fs.existsSync(absPath)) throw new Error(`Markdown file not found: ${absPath}`);
|
||||
const meta = parseMarkdownFile(absPath);
|
||||
if (!title) title = meta.title;
|
||||
if (!content) content = meta.content;
|
||||
console.log(`[wechat-browser] Parsed markdown: title="${meta.title}", content=${meta.content.length} chars`);
|
||||
}
|
||||
|
||||
if (options.imagesDir) {
|
||||
const absDir = path.isAbsolute(options.imagesDir) ? options.imagesDir : path.resolve(process.cwd(), options.imagesDir);
|
||||
if (!fs.existsSync(absDir)) throw new Error(`Images directory not found: ${absDir}`);
|
||||
images = await loadImagesFromDir(absDir);
|
||||
console.log(`[wechat-browser] Found ${images.length} images in ${absDir}`);
|
||||
}
|
||||
|
||||
if (title.length > 20) {
|
||||
const original = title;
|
||||
title = compressTitle(title, 20);
|
||||
console.log(`[wechat-browser] Title compressed: "${original}" → "${title}"`);
|
||||
}
|
||||
|
||||
if (content.length > 1000) {
|
||||
const original = content.length;
|
||||
content = compressContent(content, 1000);
|
||||
console.log(`[wechat-browser] Content compressed: ${original} → ${content.length} chars`);
|
||||
}
|
||||
|
||||
if (!title) throw new Error('Title is required (use --title or --markdown)');
|
||||
if (!content) throw new Error('Content is required (use --content or --markdown)');
|
||||
if (images.length === 0) throw new Error('At least one image is required (use --image or --images)');
|
||||
|
||||
for (const img of images) {
|
||||
if (!fs.existsSync(img)) throw new Error(`Image not found: ${img}`);
|
||||
}
|
||||
|
||||
const chromePath = options.chromePath ?? findChromeExecutable();
|
||||
if (!chromePath) throw new Error('Chrome not found. Set WECHAT_BROWSER_CHROME_PATH env var.');
|
||||
|
||||
await mkdir(profileDir, { recursive: true });
|
||||
|
||||
const port = await getFreePort();
|
||||
console.log(`[wechat-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',
|
||||
WECHAT_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('mp.weixin.qq.com'));
|
||||
|
||||
if (!pageTarget) {
|
||||
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: WECHAT_URL });
|
||||
pageTarget = { targetId, url: WECHAT_URL, type: 'page' };
|
||||
}
|
||||
|
||||
let { 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('[wechat-browser] Waiting for page load...');
|
||||
await sleep(3000);
|
||||
|
||||
const checkLoginStatus = async (): Promise<boolean> => {
|
||||
const result = await cdp!.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `window.location.href`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
return result.result.value.includes('/cgi-bin/home');
|
||||
};
|
||||
|
||||
const waitForLogin = async (): Promise<boolean> => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await checkLoginStatus()) return true;
|
||||
await sleep(2000);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
let isLoggedIn = await checkLoginStatus();
|
||||
if (!isLoggedIn) {
|
||||
console.log('[wechat-browser] Not logged in. Please scan QR code to log in...');
|
||||
isLoggedIn = await waitForLogin();
|
||||
if (!isLoggedIn) throw new Error('Timed out waiting for login. Please log in first.');
|
||||
}
|
||||
console.log('[wechat-browser] Logged in.');
|
||||
|
||||
await sleep(2000);
|
||||
|
||||
console.log('[wechat-browser] Looking for "图文" menu...');
|
||||
const menuResult = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
const menuItems = document.querySelectorAll('.new-creation__menu .new-creation__menu-item');
|
||||
const count = menuItems.length;
|
||||
const texts = Array.from(menuItems).map(m => m.querySelector('.new-creation__menu-title')?.textContent?.trim() || m.textContent?.trim() || '');
|
||||
JSON.stringify({ count, texts });
|
||||
`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
console.log(`[wechat-browser] Menu items: ${menuResult.result.value}`);
|
||||
|
||||
const getTargets = async () => {
|
||||
return await cdp!.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
||||
};
|
||||
|
||||
const initialTargets = await getTargets();
|
||||
const initialIds = new Set(initialTargets.targetInfos.map(t => t.targetId));
|
||||
console.log(`[wechat-browser] Initial targets count: ${initialTargets.targetInfos.length}`);
|
||||
|
||||
console.log('[wechat-browser] Finding "图文" menu position...');
|
||||
const menuPos = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
(function() {
|
||||
const menuItems = document.querySelectorAll('.new-creation__menu .new-creation__menu-item');
|
||||
console.log('Found menu items:', menuItems.length);
|
||||
for (const item of menuItems) {
|
||||
const title = item.querySelector('.new-creation__menu-title');
|
||||
const text = title?.textContent?.trim() || '';
|
||||
console.log('Menu item text:', text);
|
||||
if (text === '图文') {
|
||||
item.scrollIntoView({ block: 'center' });
|
||||
const rect = item.getBoundingClientRect();
|
||||
console.log('Found 图文,rect:', JSON.stringify(rect));
|
||||
return JSON.stringify({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2, width: rect.width, height: rect.height });
|
||||
}
|
||||
}
|
||||
return 'null';
|
||||
})()
|
||||
`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
console.log(`[wechat-browser] Menu position: ${menuPos.result.value}`);
|
||||
|
||||
const pos = menuPos.result.value !== 'null' ? JSON.parse(menuPos.result.value) : null;
|
||||
if (!pos) throw new Error('图文 menu not found or not visible');
|
||||
|
||||
console.log('[wechat-browser] Clicking "图文" menu with mouse events...');
|
||||
await cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed',
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
}, { sessionId });
|
||||
await sleep(100);
|
||||
await cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mouseReleased',
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
}, { sessionId });
|
||||
|
||||
console.log('[wechat-browser] Waiting for editor...');
|
||||
await sleep(3000);
|
||||
|
||||
const waitForEditor = async (): Promise<{ targetId: string; isNewTab: boolean } | null> => {
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < 30_000) {
|
||||
const targets = await getTargets();
|
||||
const pageTargets = targets.targetInfos.filter(t => t.type === 'page');
|
||||
|
||||
for (const t of pageTargets) {
|
||||
console.log(`[wechat-browser] Target: ${t.url}`);
|
||||
}
|
||||
|
||||
const newTab = pageTargets.find(t => !initialIds.has(t.targetId) && t.url.includes('mp.weixin.qq.com'));
|
||||
if (newTab) {
|
||||
console.log(`[wechat-browser] Found new tab: ${newTab.url}`);
|
||||
return { targetId: newTab.targetId, isNewTab: true };
|
||||
}
|
||||
|
||||
const editorTab = pageTargets.find(t => t.url.includes('appmsg'));
|
||||
if (editorTab) {
|
||||
console.log(`[wechat-browser] Found editor tab: ${editorTab.url}`);
|
||||
return { targetId: editorTab.targetId, isNewTab: !initialIds.has(editorTab.targetId) };
|
||||
}
|
||||
|
||||
const currentUrl = await cdp!.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `window.location.href`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
console.log(`[wechat-browser] Current page URL: ${currentUrl.result.value}`);
|
||||
|
||||
if (currentUrl.result.value.includes('appmsg')) {
|
||||
console.log(`[wechat-browser] Current page navigated to editor`);
|
||||
return { targetId: pageTarget!.targetId, isNewTab: false };
|
||||
}
|
||||
|
||||
await sleep(1000);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const editorInfo = await waitForEditor();
|
||||
if (!editorInfo) {
|
||||
const finalTargets = await getTargets();
|
||||
console.log(`[wechat-browser] Final targets: ${finalTargets.targetInfos.filter(t => t.type === 'page').map(t => t.url).join(', ')}`);
|
||||
throw new Error('Editor not found.');
|
||||
}
|
||||
|
||||
if (editorInfo.isNewTab) {
|
||||
console.log('[wechat-browser] Switching to editor tab...');
|
||||
const editorSession = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: editorInfo.targetId, flatten: true });
|
||||
sessionId = editorSession.sessionId;
|
||||
|
||||
await cdp.send('Page.enable', {}, { sessionId });
|
||||
await cdp.send('Runtime.enable', {}, { sessionId });
|
||||
await cdp.send('DOM.enable', {}, { sessionId });
|
||||
} else {
|
||||
console.log('[wechat-browser] Editor opened in current page');
|
||||
}
|
||||
|
||||
await cdp.send('Page.enable', {}, { sessionId });
|
||||
await cdp.send('Runtime.enable', {}, { sessionId });
|
||||
await cdp.send('DOM.enable', {}, { sessionId });
|
||||
|
||||
await sleep(2000);
|
||||
|
||||
console.log('[wechat-browser] Uploading all images at once...');
|
||||
const absolutePaths = images.map(p => path.isAbsolute(p) ? p : path.resolve(process.cwd(), p));
|
||||
console.log(`[wechat-browser] Images: ${absolutePaths.join(', ')}`);
|
||||
|
||||
const { root } = await cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {}, { sessionId });
|
||||
const { nodeId } = await cdp.send<{ nodeId: number }>('DOM.querySelector', {
|
||||
nodeId: root.nodeId,
|
||||
selector: '.js_upload_btn_container input[type=file]',
|
||||
}, { sessionId });
|
||||
|
||||
if (!nodeId) throw new Error('File input not found');
|
||||
|
||||
await cdp.send('DOM.setFileInputFiles', {
|
||||
nodeId,
|
||||
files: absolutePaths,
|
||||
}, { sessionId });
|
||||
|
||||
await sleep(1000);
|
||||
|
||||
console.log('[wechat-browser] Filling title...');
|
||||
await cdp.send('Runtime.evaluate', {
|
||||
expression: `
|
||||
const titleInput = document.querySelector('#title');
|
||||
if (titleInput) {
|
||||
titleInput.value = ${JSON.stringify(title)};
|
||||
titleInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
} else {
|
||||
throw new Error('Title input not found');
|
||||
}
|
||||
`,
|
||||
}, { sessionId });
|
||||
await sleep(500);
|
||||
|
||||
console.log('[wechat-browser] Clicking on content editor...');
|
||||
const editorPos = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
(function() {
|
||||
const editor = document.querySelector('.js_pmEditorArea');
|
||||
if (editor) {
|
||||
const rect = editor.getBoundingClientRect();
|
||||
return JSON.stringify({ x: rect.x + 50, y: rect.y + 20 });
|
||||
}
|
||||
return 'null';
|
||||
})()
|
||||
`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
|
||||
if (editorPos.result.value === 'null') throw new Error('Content editor not found');
|
||||
const editorClickPos = JSON.parse(editorPos.result.value);
|
||||
|
||||
await cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed',
|
||||
x: editorClickPos.x,
|
||||
y: editorClickPos.y,
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
}, { sessionId });
|
||||
await sleep(50);
|
||||
await cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mouseReleased',
|
||||
x: editorClickPos.x,
|
||||
y: editorClickPos.y,
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
}, { sessionId });
|
||||
await sleep(300);
|
||||
|
||||
console.log('[wechat-browser] Typing content with keyboard simulation...');
|
||||
const lines = content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.length > 0) {
|
||||
await cdp.send('Input.insertText', { text: line }, { sessionId });
|
||||
}
|
||||
if (i < lines.length - 1) {
|
||||
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(50);
|
||||
}
|
||||
console.log('[wechat-browser] Content typed.');
|
||||
await sleep(500);
|
||||
|
||||
if (submit) {
|
||||
console.log('[wechat-browser] Saving as draft...');
|
||||
await cdp.send('Runtime.evaluate', {
|
||||
expression: `document.querySelector('#js_submit')?.click()`,
|
||||
}, { sessionId });
|
||||
await sleep(3000);
|
||||
console.log('[wechat-browser] Draft saved!');
|
||||
} else {
|
||||
console.log('[wechat-browser] Article composed (preview mode). Add --submit to save as draft.');
|
||||
}
|
||||
} finally {
|
||||
if (cdp) {
|
||||
cdp.close();
|
||||
}
|
||||
console.log('[wechat-browser] Done. Browser window left open.');
|
||||
}
|
||||
}
|
||||
|
||||
function printUsage(): never {
|
||||
console.log(`Post image-text (图文) to WeChat Official Account
|
||||
|
||||
Usage:
|
||||
npx -y bun wechat-browser.ts [options]
|
||||
|
||||
Options:
|
||||
--markdown <path> Markdown file for title/content extraction
|
||||
--images <dir> Directory containing images (PNG/JPG)
|
||||
--title <text> Article title (max 20 chars, auto-compressed)
|
||||
--content <text> Article content (max 1000 chars, auto-compressed)
|
||||
--image <path> Add image (can be repeated)
|
||||
--submit Save as draft (default: preview only)
|
||||
--profile <dir> Chrome profile directory
|
||||
--help Show this help
|
||||
|
||||
Examples:
|
||||
npx -y bun wechat-browser.ts --markdown article.md --images ./photos/
|
||||
npx -y bun wechat-browser.ts --title "测试" --content "内容" --image ./photo.png
|
||||
npx -y bun wechat-browser.ts --markdown article.md --images ./photos/ --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;
|
||||
let title: string | undefined;
|
||||
let content: string | undefined;
|
||||
let markdownFile: string | undefined;
|
||||
let imagesDir: string | undefined;
|
||||
|
||||
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 === '--images' && args[i + 1]) {
|
||||
imagesDir = args[++i];
|
||||
} else if (arg === '--title' && args[i + 1]) {
|
||||
title = args[++i];
|
||||
} else if (arg === '--content' && args[i + 1]) {
|
||||
content = args[++i];
|
||||
} else if (arg === '--markdown' && args[i + 1]) {
|
||||
markdownFile = args[++i];
|
||||
} else if (arg === '--submit') {
|
||||
submit = true;
|
||||
} else if (arg === '--profile' && args[i + 1]) {
|
||||
profileDir = args[++i];
|
||||
}
|
||||
}
|
||||
|
||||
if (!markdownFile && !title) {
|
||||
console.error('Error: --title or --markdown is required');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!markdownFile && !content) {
|
||||
console.error('Error: --content or --markdown is required');
|
||||
process.exit(1);
|
||||
}
|
||||
if (images.length === 0 && !imagesDir) {
|
||||
console.error('Error: --image or --images is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await postToWeChat({ title, content, images: images.length > 0 ? images : undefined, imagesDir, markdownFile, submit, profileDir });
|
||||
}
|
||||
|
||||
await main().catch((err) => {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
---
|
||||
name: baoyu-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
|
||||
/baoyu-post-to-x "Your post content here"
|
||||
|
||||
# Post with image
|
||||
/baoyu-post-to-x "Your post content" --image /path/to/image.png
|
||||
|
||||
# Post with multiple images (up to 4)
|
||||
/baoyu-post-to-x "Your post content" --image img1.png --image img2.png
|
||||
|
||||
# Actually submit the post
|
||||
/baoyu-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: /baoyu-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)
|
||||
/baoyu-post-to-x article /path/to/article.md
|
||||
|
||||
# With custom cover image
|
||||
/baoyu-post-to-x article article.md --cover ./hero.png
|
||||
|
||||
# With custom title
|
||||
/baoyu-post-to-x article article.md --title "My Custom Title"
|
||||
|
||||
# Actually publish (not just draft)
|
||||
/baoyu-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: /baoyu-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,439 @@
|
||||
---
|
||||
name: baoyu-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
|
||||
/baoyu-slide-deck path/to/article.md
|
||||
|
||||
# With style preference
|
||||
/baoyu-slide-deck path/to/article.md --style corporate
|
||||
/baoyu-slide-deck path/to/article.md --style playful
|
||||
/baoyu-slide-deck path/to/article.md --style technical
|
||||
|
||||
# With audience specification
|
||||
/baoyu-slide-deck path/to/article.md --audience beginners
|
||||
/baoyu-slide-deck path/to/article.md --audience executives
|
||||
|
||||
# With language
|
||||
/baoyu-slide-deck path/to/article.md --lang zh
|
||||
/baoyu-slide-deck path/to/article.md --lang en
|
||||
|
||||
# Outline only (no image generation)
|
||||
/baoyu-slide-deck path/to/article.md --outline-only
|
||||
|
||||
# Direct content input
|
||||
/baoyu-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
|
||||
|
||||
### 7. `warm`
|
||||
Cozy, healing, hand-drawn illustration style
|
||||
- **Aesthetic**: Soft hand-drawn illustrations, cozy and healing atmosphere, gentle curves, watercolor textures
|
||||
- **Colors**: Cream gradient background (#FFF8E7 → #FFE4C4), warm brown text (#5D4037), soft coral accents (#FF8A80)
|
||||
- **Best for**: Personal growth, wellness, emotional content, lifestyle topics
|
||||
|
||||
### 8. `retro-flat`
|
||||
Flat vector illustration with retro palette
|
||||
- **Aesthetic**: Flat vector with clear black outlines (monoline), geometric simplification, toy-model cuteness, NO gradients
|
||||
- **Colors**: Retro muted palette - dusty pink (#E8B4B8), sage green (#A8C686), mustard yellow (#E8B84A), off-white background (#F5F0E6)
|
||||
- **Best for**: Tutorials, explainers, product introductions, educational content
|
||||
|
||||
### 9. `notion`
|
||||
Minimalist hand-drawn line art, intellectual
|
||||
- **Aesthetic**: Simple line doodles with hand-drawn wobble, geometric shapes, maximum whitespace, SaaS product feel
|
||||
- **Colors**: Black outlines (#1A1A1A), white background (#FFFFFF), 1-2 pastel accents (blue #A8D4F0, yellow #F9E79F)
|
||||
- **Best for**: Knowledge sharing, concept explanations, productivity content, SaaS presentations
|
||||
|
||||
## 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
|
||||
|
||||
```
|
||||
./baoyu-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` |
|
||||
| Wellness, healing, cozy, self-care, lifestyle, comfort | `warm` |
|
||||
| Tutorial, explainer, how-to, beginner, product, guide | `retro-flat` |
|
||||
| Knowledge, concept, productivity, SaaS, notion, intellectual | `notion` |
|
||||
|
||||
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
|
||||
/baoyu-gemini-web --promptfiles [SKILL_ROOT]/skills/baoyu-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
|
||||
```
|
||||
|
||||
### warm
|
||||
```
|
||||
Design Aesthetic: Cozy healing hand-drawn illustration style, soft sketch lines, warm and comforting atmosphere
|
||||
Background Color: Cream gradient #FFF8E7 → #FFE4C4
|
||||
Primary Font: Rounded hand-drawn style lettering
|
||||
Secondary Font: Soft serif hand lettering
|
||||
Primary Text Color: Warm brown #5D4037
|
||||
Primary Accent Color: Soft coral #FF8A80, Peachy pink #FFAB91
|
||||
Visual Elements: Soft watercolor textures, gentle curves, cozy illustrations, warm lighting, hearts and stars, plant motifs, cute characters
|
||||
```
|
||||
|
||||
### retro-flat
|
||||
```
|
||||
Design Aesthetic: Flat vector illustration with clear uniform-width black outlines, geometric simplification, toy-model cuteness
|
||||
Background Color: Off-white #F5F0E6, soft cream
|
||||
Primary Font: Bold geometric sans-serif with black outline
|
||||
Secondary Font: Clean sans-serif
|
||||
Primary Text Color: Black #000000 (clean outlines)
|
||||
Primary Accent Color: Retro muted palette - Dusty pink #E8B4B8, Sage green #A8C686, Mustard yellow #E8B84A, Muted blue #7BA7BC
|
||||
Visual Elements: Clear black monoline outlines (uniform width), flat color fills with NO gradients, geometric simplified shapes, toy-like proportions, minimal shadows, vintage badge style, halftone dots optional
|
||||
Style Rules: MUST use clear black outlines on all elements, NO 3D effects, NO gradients, simple flat color blocks only
|
||||
```
|
||||
|
||||
### notion
|
||||
```
|
||||
Design Aesthetic: Minimalist hand-drawn line art with intellectual SaaS product feel, Notion-style doodles
|
||||
Background Color: Pure white #FFFFFF, off-white #FAFAFA
|
||||
Primary Font: Clean hand-drawn lettering, simple sans-serif
|
||||
Secondary Font: Simple sans-serif labels
|
||||
Primary Text Color: Black #1A1A1A, dark gray #4A4A4A
|
||||
Primary Accent Color: Pastel blue #A8D4F0, Pastel yellow #F9E79F, Pastel pink #FADBD8
|
||||
Visual Elements: Simple line doodles with hand-drawn wobble effect, geometric shapes, stick figures, maximum whitespace, single-weight ink lines
|
||||
Style Rules: Single color lines (black/dark gray), 1-2 pastel accents only, NO complex gradients, NO heavy shadows, imperfect hand-drawn feel
|
||||
```
|
||||
|
||||
## 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,533 @@
|
||||
---
|
||||
name: baoyu-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 and layout based on content
|
||||
/baoyu-xhs-images posts/ai-future/article.md
|
||||
|
||||
# Specify style
|
||||
/baoyu-xhs-images posts/ai-future/article.md --style notion
|
||||
|
||||
# Specify layout
|
||||
/baoyu-xhs-images posts/ai-future/article.md --layout dense
|
||||
|
||||
# Combine style and layout
|
||||
/baoyu-xhs-images posts/ai-future/article.md --style tech --layout list
|
||||
|
||||
# Direct content input
|
||||
/baoyu-xhs-images
|
||||
[paste content]
|
||||
|
||||
# Direct input with options
|
||||
/baoyu-xhs-images --style bold --layout comparison
|
||||
[paste content]
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--style <name>` | Visual style (see Style Gallery) |
|
||||
| `--layout <name>` | Information layout (see Layout Gallery) |
|
||||
|
||||
## Two Dimensions
|
||||
|
||||
| Dimension | Controls | Options |
|
||||
|-----------|----------|---------|
|
||||
| **Style** | Visual aesthetics: colors, lines, decorations | cute, fresh, tech, warm, bold, minimal, retro, pop, notion |
|
||||
| **Layout** | Information structure: density, arrangement | sparse, balanced, dense, list, comparison, flow |
|
||||
|
||||
Style × Layout can be freely combined. Example: `--style notion --layout dense` creates an intellectual-looking knowledge card with high information density.
|
||||
|
||||
## 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
|
||||
|
||||
### 9. `notion`
|
||||
Minimalist hand-drawn line art, intellectual
|
||||
- **Colors**: Black outlines, white background, 1-2 pastel accents
|
||||
- **Elements**: Simple line doodles, geometric shapes, hand-drawn wobble, maximum whitespace
|
||||
- **Best for**: Knowledge sharing, concept explanations, SaaS content, productivity tips
|
||||
|
||||
## Layout Gallery
|
||||
|
||||
### 1. `sparse` (Default)
|
||||
Minimal information, maximum impact
|
||||
- **Density**: 1-2 key points per image
|
||||
- **Whitespace**: 60-70% of canvas
|
||||
- **Structure**: Single focal point, one core message
|
||||
- **Best for**: Covers, quotes, impactful statements, emotional content
|
||||
|
||||
### 2. `balanced`
|
||||
Standard content layout
|
||||
- **Density**: 3-4 key points per image
|
||||
- **Whitespace**: 40-50% of canvas
|
||||
- **Structure**: Title + 3-4 bullet points or sections
|
||||
- **Best for**: Regular content pages, tutorials, explanations
|
||||
|
||||
### 3. `dense`
|
||||
High information density, knowledge card style
|
||||
- **Density**: 5-8 key points per image
|
||||
- **Whitespace**: 20-30% of canvas
|
||||
- **Structure**: Multiple sections, structured grid, more text
|
||||
- **Best for**: Summary cards, cheat sheets, comprehensive guides, 干货总结
|
||||
|
||||
### 4. `list`
|
||||
Enumeration and ranking format
|
||||
- **Density**: 4-7 items
|
||||
- **Whitespace**: 30-40% of canvas
|
||||
- **Structure**: Numbered or bulleted vertical list, consistent item format
|
||||
- **Best for**: Top N lists, checklists, step-by-step guides, rankings
|
||||
|
||||
### 5. `comparison`
|
||||
Side-by-side contrast layout
|
||||
- **Density**: 2 main sections with 2-4 points each
|
||||
- **Whitespace**: 30-40% of canvas
|
||||
- **Structure**: Left vs Right, Before/After, Pros/Cons
|
||||
- **Best for**: Comparisons, transformations, decision helpers, 对比图
|
||||
|
||||
### 6. `flow`
|
||||
Process and timeline layout
|
||||
- **Density**: 3-6 steps/stages
|
||||
- **Whitespace**: 30-40% of canvas
|
||||
- **Structure**: Connected nodes with arrows, sequential flow
|
||||
- **Best for**: Processes, timelines, cause-effect chains, workflows
|
||||
|
||||
## 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` |
|
||||
| Knowledge, concept, productivity, SaaS, notion | `notion` |
|
||||
|
||||
## Auto Layout Selection
|
||||
|
||||
When no `--layout` is specified, analyze content structure to select:
|
||||
|
||||
| Content Signals | Selected Layout |
|
||||
|----------------|-----------------|
|
||||
| Single quote, one key point, cover | `sparse` |
|
||||
| 3-4 points, explanation, tutorial | `balanced` |
|
||||
| 5+ points, summary, cheat sheet, 干货 | `dense` |
|
||||
| Numbered items, top N, checklist, steps | `list` |
|
||||
| vs, compare, before/after, pros/cons | `comparison` |
|
||||
| Process, flow, timeline, steps with order | `flow` |
|
||||
|
||||
**Layout by Position**:
|
||||
| Position | Recommended Layout |
|
||||
|----------|-------------------|
|
||||
| Cover | `sparse` |
|
||||
| Content | `balanced` or content-appropriate |
|
||||
| Ending | `sparse` or `balanced` |
|
||||
|
||||
## 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/Layout
|
||||
|
||||
1. Read content
|
||||
2. If `--style` specified, use that style; otherwise auto-select
|
||||
3. If `--layout` specified, use that layout; otherwise auto-select per image
|
||||
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 |
|
||||
|
||||
**Note**: Layout can vary per image in a series. Cover typically uses `sparse`, content pages use `balanced`/`dense`/`list` as appropriate.
|
||||
|
||||
### Step 2: Generate Outline
|
||||
|
||||
Plan for each image with style and layout specifications:
|
||||
|
||||
```markdown
|
||||
# Xiaohongshu Infographic Series Outline
|
||||
|
||||
**Topic**: [topic description]
|
||||
**Style**: [selected style]
|
||||
**Default Layout**: [selected layout or "varies"]
|
||||
**Image Count**: N
|
||||
**Generated**: YYYY-MM-DD HH:mm
|
||||
|
||||
---
|
||||
|
||||
## Image 1 of N
|
||||
|
||||
**Position**: Cover
|
||||
**Layout**: sparse
|
||||
**Core Message**: [one-liner]
|
||||
**Filename**: 01-cover.png
|
||||
|
||||
**Text Content**:
|
||||
- Title: xxx
|
||||
- Subtitle: xxx
|
||||
|
||||
**Visual Concept**: [style + layout appropriate description]
|
||||
|
||||
---
|
||||
|
||||
## Image 2 of N
|
||||
|
||||
**Position**: Content
|
||||
**Layout**: [balanced/dense/list/comparison/flow]
|
||||
**Core Message**: [one-liner]
|
||||
**Filename**: 02-xxx.png
|
||||
|
||||
**Text Content**:
|
||||
- Title: xxx
|
||||
- Points: [list based on layout density]
|
||||
|
||||
**Visual Concept**: [description matching style + layout]
|
||||
|
||||
---
|
||||
...
|
||||
```
|
||||
|
||||
### Step 3: Save Outline
|
||||
|
||||
Save outline as `outline.md`.
|
||||
|
||||
### Step 4: Generate Images One by One
|
||||
|
||||
For each image, create a prompt file with style and layout specifications.
|
||||
|
||||
**Prompt Format**:
|
||||
|
||||
```markdown
|
||||
Infographic theme: [topic]
|
||||
Style: [style name]
|
||||
Layout: [layout name]
|
||||
Position: [cover/content/ending]
|
||||
|
||||
Visual composition:
|
||||
- Main visual: [style-appropriate description]
|
||||
- Arrangement: [layout-specific structure]
|
||||
- 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: [based on layout density]
|
||||
|
||||
Layout instructions: [layout-specific guidance]
|
||||
Style notes: [style-specific characteristics]
|
||||
```
|
||||
|
||||
**Layout-Specific Instructions**:
|
||||
|
||||
| Layout | Arrangement Instructions |
|
||||
|--------|-------------------------|
|
||||
| `sparse` | Single focal point centered, 1-2 text elements, maximum breathing room |
|
||||
| `balanced` | Title at top, 3-4 points in clear sections, moderate spacing |
|
||||
| `dense` | Grid or multi-section layout, 5-8 points, compact but organized |
|
||||
| `list` | Vertical numbered/bulleted list, consistent item spacing, clear hierarchy |
|
||||
| `comparison` | Two-column split, clear divider, mirrored structure left/right |
|
||||
| `flow` | Horizontal or vertical flow with arrows, connected nodes/steps |
|
||||
|
||||
**Image Generation Skill Selection**:
|
||||
1. Check available image generation skills
|
||||
2. If multiple skills available, ask user to choose
|
||||
|
||||
**Generation Flow**:
|
||||
1. Call selected image generation skill with prompt file and output path
|
||||
2. Confirm generation success
|
||||
3. Report progress: "Generated X/N"
|
||||
4. Continue to next
|
||||
|
||||
### Step 5: Completion Report
|
||||
|
||||
```
|
||||
Xiaohongshu Infographic Series Complete!
|
||||
|
||||
Topic: [topic]
|
||||
Style: [style name]
|
||||
Layout: [layout name or "varies"]
|
||||
Location: [directory path]
|
||||
Images: N total
|
||||
|
||||
- 01-cover.png ✓ Cover (sparse)
|
||||
- 02-content-1.png ✓ Content (balanced)
|
||||
- 03-content-2.png ✓ Content (dense)
|
||||
- 04-ending.png ✓ Ending (sparse)
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
### notion
|
||||
```
|
||||
Colors: Black (#1A1A1A), dark gray (#4A4A4A)
|
||||
Background: Pure white (#FFFFFF), off-white (#FAFAFA)
|
||||
Accents: Pastel blue (#A8D4F0), pastel yellow (#F9E79F), pastel pink (#FADBD8)
|
||||
Elements: Simple line doodles, hand-drawn wobble effect, geometric shapes, stick figures, maximum whitespace
|
||||
Typography: Clean hand-drawn lettering, simple sans-serif labels
|
||||
```
|
||||
|
||||
## Layout Reference Details
|
||||
|
||||
### sparse
|
||||
```
|
||||
Information Density: Very Low (1-2 points)
|
||||
Whitespace: 60-70%
|
||||
Structure: Single centered focal point
|
||||
Text Elements: Title only, or title + one subtitle/tagline
|
||||
Visual Balance: Centered, symmetrical, breathing room on all sides
|
||||
Best Pairing: Any style, especially effective with bold, minimal, notion
|
||||
```
|
||||
|
||||
### balanced
|
||||
```
|
||||
Information Density: Medium (3-4 points)
|
||||
Whitespace: 40-50%
|
||||
Structure: Title at top, content sections below
|
||||
Text Elements: Title + 3-4 bullet points or key messages
|
||||
Visual Balance: Top-weighted title, evenly distributed content below
|
||||
Best Pairing: All styles work well
|
||||
```
|
||||
|
||||
### dense
|
||||
```
|
||||
Information Density: High (5-8 points)
|
||||
Whitespace: 20-30%
|
||||
Structure: Multi-section grid or stacked blocks
|
||||
Text Elements: Title + multiple sections with headers + numerous points
|
||||
Visual Balance: Organized chaos, clear section boundaries, compact spacing
|
||||
Best Pairing: tech, notion, minimal (clean styles prevent visual overload)
|
||||
```
|
||||
|
||||
### list
|
||||
```
|
||||
Information Density: Medium-High (4-7 items)
|
||||
Whitespace: 30-40%
|
||||
Structure: Vertical enumeration with numbers or bullets
|
||||
Text Elements: Title + numbered/bulleted items, consistent format per item
|
||||
Visual Balance: Left-aligned list, clear number/bullet hierarchy
|
||||
Best Pairing: All styles, especially cute (checklist), bold (rankings)
|
||||
```
|
||||
|
||||
### comparison
|
||||
```
|
||||
Information Density: Medium (2×2-4 points)
|
||||
Whitespace: 30-40%
|
||||
Structure: Two-column split with center divider
|
||||
Text Elements: Title + left label + right label + mirrored points
|
||||
Visual Balance: Symmetrical left/right, clear visual contrast
|
||||
Best Pairing: bold (dramatic contrast), tech (data comparison), warm (before/after stories)
|
||||
```
|
||||
|
||||
### flow
|
||||
```
|
||||
Information Density: Medium (3-6 steps)
|
||||
Whitespace: 30-40%
|
||||
Structure: Connected nodes with directional arrows
|
||||
Text Elements: Title + step labels + optional descriptions per step
|
||||
Visual Balance: Directional flow (top→bottom or left→right), clear progression
|
||||
Best Pairing: tech (process diagrams), notion (simple flows), fresh (organic flows)
|
||||
```
|
||||
|
||||
## Content Breakdown Principles
|
||||
|
||||
1. **Cover (Image 1)**: Strong visual impact, core title, attention hook → `sparse` layout
|
||||
2. **Content (Middle)**: Core points per image, density varies by content → `balanced`/`dense`/`list`/`comparison`/`flow`
|
||||
3. **Ending (Last)**: Summary / call-to-action / memorable quote → `sparse` or `balanced`
|
||||
|
||||
**Style × Layout Matrix** (recommended combinations):
|
||||
|
||||
| | sparse | balanced | dense | list | comparison | flow |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| cute | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ |
|
||||
| fresh | ✓✓ | ✓✓ | ✓ | ✓ | ✓ | ✓✓ |
|
||||
| tech | ✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ |
|
||||
| warm | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓ |
|
||||
| bold | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
| minimal | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓ |
|
||||
| retro | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ |
|
||||
| pop | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
| notion | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ |
|
||||
|
||||
✓✓ = highly recommended, ✓ = works well
|
||||
|
||||
## 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