Compare commits

..

13 Commits

Author SHA1 Message Date
Jim Liu 宝玉 9e2ee0659b chore: release v1.15.2 2026-01-23 12:02:37 -06:00
Jim Liu 宝玉 94c9309aa6 chore: release v1.15.1 2026-01-22 16:21:11 -06:00
Jim Liu 宝玉 eb92bdb9df chore: release v1.15.0 2026-01-22 11:47:56 -06:00
Jim Liu 宝玉 e07d33fed0 chore: release v1.14.0 2026-01-22 01:24:52 -06:00
Jim Liu 宝玉 e964feb5e5 Merge pull request #14 from fkysly/fix/video-ready-detection
fix(x-video): improve video ready detection
2026-01-21 21:42:55 -06:00
fkysly 7669556736 fix(x-video): improve video ready detection
- Type text while video uploads in background instead of waiting
- Fix video ready detection by checking tweet button state instead of
  progressbar presence (X always has progressbar elements for video
  playback, causing false negatives)
2026-01-22 09:56:02 +08:00
Jim Liu 宝玉 97da7ab4eb chore: release v1.13.0 2026-01-21 19:40:46 -06:00
Jim Liu 宝玉 235868343c chore: release v1.12.0 2026-01-21 11:28:41 -06:00
Jim Liu 宝玉 f43dc2be56 Merge pull request #13 from threehotpot-bot/feature/quote-tweet
feat: add quote tweet support
2026-01-21 10:39:22 -06:00
Jim Liu 宝玉 776afba5d8 chore: release v1.11.0 2026-01-21 10:16:12 -06:00
threenrm d793c19a72 fix: use CDP Input.insertText for reliable text insertion
The previous document.execCommand('insertText') caused text to be
duplicated multiple times. Switch to CDP's Input.insertText for
more reliable text input.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 18:21:36 +08:00
threenrm 8cc8d25ad1 feat: add quote tweet support
Add x-quote.ts script to quote existing tweets with comments.
This allows users to share content while giving credit to the original creator.

- New script: scripts/x-quote.ts for quote tweet functionality
- Uses CDP to navigate through X's native quote UI
- Supports preview mode and --submit flag
- Updated SKILL.md with documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 18:14:54 +08:00
Jim Liu 宝玉 0d677ea171 docs: Update readme, use npx skills add to install 2026-01-21 01:59:42 -06:00
59 changed files with 5148 additions and 1318 deletions
+5 -3
View File
@@ -6,7 +6,7 @@
}, },
"metadata": { "metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency", "description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.10.0" "version": "1.15.2"
}, },
"plugins": [ "plugins": [
{ {
@@ -31,7 +31,8 @@
"source": "./", "source": "./",
"strict": false, "strict": false,
"skills": [ "skills": [
"./skills/baoyu-danger-gemini-web" "./skills/baoyu-danger-gemini-web",
"./skills/baoyu-image-gen"
] ]
}, },
{ {
@@ -41,7 +42,8 @@
"strict": false, "strict": false,
"skills": [ "skills": [
"./skills/baoyu-danger-x-to-markdown", "./skills/baoyu-danger-x-to-markdown",
"./skills/baoyu-compress-image" "./skills/baoyu-compress-image",
"./skills/baoyu-url-to-markdown"
] ]
} }
] ]
+289 -171
View File
@@ -1,197 +1,315 @@
--- ---
name: release-skills name: release-skills
description: Release workflow for baoyu-skills plugin. Use when user says "release", "发布", "push", "推送", "new version", "新版本", "bump version", "更新版本", or wants to publish changes to remote. Analyzes changes since last tag, updates CHANGELOG (EN/CN), bumps marketplace.json version, commits, and creates version tag. MUST be used before any git push with uncommitted skill changes. description: Universal release workflow. Auto-detects version files and changelogs. Supports Node.js, Python, Rust, Claude Plugin, and generic projects. Use when user says "release", "发布", "new version", "bump version", "push", "推送".
--- ---
# Release Skills # Release Skills
Automate the release process for baoyu-skills plugin: analyze changes, update changelogs, bump version, commit, and tag. Universal release workflow supporting any project type with multi-language changelog.
## CRITICAL: Mandatory Release Checklist ## Quick Start
**NEVER skip these steps when releasing:** Just run `/release-skills` - auto-detects your project configuration.
1. ✅ Update `CHANGELOG.md` (English) ## Supported Projects
2. ✅ Update `CHANGELOG.zh.md` (Chinese)
3. ✅ Update `marketplace.json` version
4. ✅ Update `README.md` / `README.zh.md` if needed
5. ✅ Commit all changes together
6. ✅ Create version tag
**If user says "直接 push" or "just push" - STILL follow all steps above first!** | Project Type | Version File | Auto-Detected |
|--------------|--------------|---------------|
## When to Use | Node.js | package.json | ✓ |
| Python | pyproject.toml | ✓ |
Trigger this skill when user requests: | Rust | Cargo.toml | ✓ |
- "release", "发布", "create release", "new version" | Claude Plugin | marketplace.json | ✓ |
- "bump version", "update version" | Generic | VERSION / version.txt | ✓ |
- "prepare release"
- "push to remote" (with uncommitted changes)
## Workflow
### Step 1: Analyze Changes Since Last Tag
```bash
# Get the latest version tag
LAST_TAG=$(git tag --sort=-v:refname | head -1)
# Show changes since last tag
git log ${LAST_TAG}..HEAD --oneline
git diff ${LAST_TAG}..HEAD --stat
```
Categorize changes by type based on commit messages and file changes:
| Type | Prefix | Description |
|------|--------|-------------|
| feat | `feat:` | New features, new skills |
| fix | `fix:` | Bug fixes |
| docs | `docs:` | Documentation only |
| refactor | `refactor:` | Code refactoring |
| style | `style:` | Formatting, styling |
| chore | `chore:` | Build, tooling, maintenance |
**Breaking Change Detection**: If changes include:
- Removed skills or scripts
- Changed API/interfaces
- Renamed public functions/options
Warn user: "Breaking changes detected. Consider major version bump (--major flag)."
### Step 2: Determine Version Bump
Current version location: `.claude-plugin/marketplace.json``metadata.version`
Version rules:
- **Patch** (0.6.1 → 0.6.2): Bug fixes, docs updates, minor improvements
- **Minor** (0.6.x → 0.7.0): New features, new skills, significant enhancements
- **Major** (0.x → 1.0): Breaking changes, only when user explicitly requests with `--major`
Default behavior:
- If changes include `feat:` or new skills → Minor bump
- Otherwise → Patch bump
### Step 3: Check and Update README
Before updating changelogs, check if README files need updates based on changes:
**When to update README**:
- New skills added → Add to skill list
- Skills removed → Remove from skill list
- Skill renamed → Update references
- New features affecting usage → Update usage section
- Breaking changes → Update migration notes
**Files to sync**:
- `README.md` (English)
- `README.zh.md` (Chinese)
If changes include new skills or significant feature changes, update both README files to reflect the new capabilities. Keep both files in sync with the same structure and information.
### Step 4: Update Changelogs
Files to update:
- `CHANGELOG.md` (English)
- `CHANGELOG.zh.md` (Chinese)
Format (insert after header, before previous version):
```markdown
## {NEW_VERSION} - {YYYY-MM-DD}
### Features
- `skill-name`: description of new feature
### Fixes
- `skill-name`: description of fix
### Documentation
- description of docs changes
### Other
- description of other changes
```
Only include sections that have changes. Omit empty sections.
For Chinese changelog, translate the content maintaining the same structure.
### Step 5: Update marketplace.json
Update `.claude-plugin/marketplace.json`:
```json
{
"metadata": {
"version": "{NEW_VERSION}"
}
}
```
### Step 6: Commit Changes
```bash
git add README.md README.zh.md CHANGELOG.md CHANGELOG.zh.md .claude-plugin/marketplace.json
git commit -m "chore: release v{NEW_VERSION}"
```
**Note**: Do NOT add Co-Authored-By line. This is a release commit, not a code contribution.
### Step 7: Create Version Tag
```bash
git tag v{NEW_VERSION}
```
**Important**: Do NOT push to remote. User will push manually when ready.
## Options ## Options
| Flag | Description | | Flag | Description |
|------|-------------| |------|-------------|
| `--dry-run` | Preview changes without executing. Show what would be updated. | | `--dry-run` | Preview changes without executing |
| `--major` | Force major version bump (0.x → 1.0 or 1.x → 2.0) | | `--major` | Force major version bump |
| `--minor` | Force minor version bump | | `--minor` | Force minor version bump |
| `--patch` | Force patch version bump | | `--patch` | Force patch version bump |
| `--pre <tag>` | (Reserved) Create pre-release version, e.g., `--pre beta``0.7.0-beta.1` |
## Workflow
### Step 1: Detect Project Configuration
1. Check for `.releaserc.yml` (optional config override)
2. Auto-detect version file by scanning (priority order):
- `package.json` (Node.js)
- `pyproject.toml` (Python)
- `Cargo.toml` (Rust)
- `marketplace.json` or `.claude-plugin/marketplace.json` (Claude Plugin)
- `VERSION` or `version.txt` (Generic)
3. Scan for changelog files using glob patterns:
- `CHANGELOG*.md`
- `HISTORY*.md`
- `CHANGES*.md`
4. Identify language of each changelog by filename suffix
5. Display detected configuration
**Language Detection Rules**:
| Filename Pattern | Language |
|------------------|----------|
| `CHANGELOG.md` (no suffix) | en (default) |
| `CHANGELOG.zh.md` / `CHANGELOG_CN.md` / `CHANGELOG.zh-CN.md` | zh |
| `CHANGELOG.ja.md` / `CHANGELOG_JP.md` | ja |
| `CHANGELOG.ko.md` / `CHANGELOG_KR.md` | ko |
| `CHANGELOG.de.md` / `CHANGELOG_DE.md` | de |
| `CHANGELOG.fr.md` / `CHANGELOG_FR.md` | fr |
| `CHANGELOG.es.md` / `CHANGELOG_ES.md` | es |
| `CHANGELOG.{lang}.md` | Corresponding language code |
**Output Example**:
```
Project detected:
Version file: package.json (1.2.3)
Changelogs:
- CHANGELOG.md (en)
- CHANGELOG.zh.md (zh)
- CHANGELOG.ja.md (ja)
```
### Step 2: Analyze Changes Since Last Tag
```bash
LAST_TAG=$(git tag --sort=-v:refname | head -1)
git log ${LAST_TAG}..HEAD --oneline
git diff ${LAST_TAG}..HEAD --stat
```
Categorize by conventional commit types:
| Type | Description |
|------|-------------|
| feat | New features |
| fix | Bug fixes |
| docs | Documentation |
| refactor | Code refactoring |
| perf | Performance improvements |
| test | Test changes |
| style | Formatting, styling |
| chore | Maintenance (skip in changelog) |
**Breaking Change Detection**:
- Commit message starts with `BREAKING CHANGE`
- Commit body/footer contains `BREAKING CHANGE:`
- Removed public APIs, renamed exports, changed interfaces
If breaking changes detected, warn user: "Breaking changes detected. Consider major version bump (--major flag)."
### Step 3: Determine Version Bump
Rules (in priority order):
1. User flag `--major/--minor/--patch` → Use specified
2. BREAKING CHANGE detected → Major bump (1.x.x → 2.0.0)
3. `feat:` commits present → Minor bump (1.2.x → 1.3.0)
4. Otherwise → Patch bump (1.2.3 → 1.2.4)
Display version change: `1.2.3 → 1.3.0`
### Step 4: Generate Multi-language Changelogs
For each detected changelog file:
1. **Identify language** from filename suffix
2. **Generate content in that language**:
- Section titles in target language
- Change descriptions written naturally in target language (not translated)
- Date format: YYYY-MM-DD (universal)
3. **Insert at file head** (preserve existing content)
**Section Title Translations** (built-in):
| Type | en | zh | ja | ko | de | fr | es |
|------|----|----|----|----|----|----|-----|
| feat | Features | 新功能 | 新機能 | 새로운 기능 | Funktionen | Fonctionnalités | Características |
| fix | Fixes | 修复 | 修正 | 수정 | Fehlerbehebungen | Corrections | Correcciones |
| docs | Documentation | 文档 | ドキュメント | 문서 | Dokumentation | Documentation | Documentación |
| refactor | Refactor | 重构 | リファクタリング | 리팩토링 | Refactoring | Refactorisation | Refactorización |
| perf | Performance | 性能优化 | パフォーマンス | 성능 | Leistung | Performance | Rendimiento |
| breaking | Breaking Changes | 破坏性变更 | 破壊的変更 | 주요 변경사항 | Breaking Changes | Changements majeurs | Cambios importantes |
**Changelog Format**:
```markdown
## {VERSION} - {YYYY-MM-DD}
### Features
- Description of new feature
### Fixes
- Description of fix
### Documentation
- Description of docs changes
```
Only include sections that have changes. Omit empty sections.
**Multi-language Example**:
English (CHANGELOG.md):
```markdown
## 1.3.0 - 2026-01-22
### Features
- Add user authentication module
- Support OAuth2 login
### Fixes
- Fix memory leak in connection pool
```
Chinese (CHANGELOG.zh.md):
```markdown
## 1.3.0 - 2026-01-22
### 新功能
- 新增用户认证模块
- 支持 OAuth2 登录
### 修复
- 修复连接池内存泄漏问题
```
Japanese (CHANGELOG.ja.md):
```markdown
## 1.3.0 - 2026-01-22
### 新機能
- ユーザー認証モジュールを追加
- OAuth2 ログインをサポート
### 修正
- コネクションプールのメモリリークを修正
```
### Step 5: Update Version File
1. Read version file (JSON/TOML/text)
2. Update version number
3. Write back (preserve formatting)
**Version Paths by File Type**:
| File | Path |
|------|------|
| package.json | `$.version` |
| pyproject.toml | `project.version` |
| Cargo.toml | `package.version` |
| marketplace.json | `$.metadata.version` |
| VERSION / version.txt | Direct content |
### Step 6: Commit and Tag
```bash
git add <all modified files>
git commit -m "chore: release v{VERSION}"
git tag v{VERSION}
```
**Note**: Do NOT add Co-Authored-By line. This is a release commit, not a code contribution.
**Important**: Do NOT push to remote. User will push manually when ready.
**Post-Release Output**:
```
Release v1.3.0 created locally.
To publish:
git push origin main
git push origin v1.3.0
```
## Configuration (.releaserc.yml)
Optional config file in project root to override defaults:
```yaml
# .releaserc.yml - Optional configuration
# Version file (auto-detected if not specified)
version:
file: package.json
path: $.version # JSONPath for JSON, dotted path for TOML
# Changelog files (auto-detected if not specified)
changelog:
files:
- path: CHANGELOG.md
lang: en
- path: CHANGELOG.zh.md
lang: zh
- path: CHANGELOG.ja.md
lang: ja
# Section mapping (conventional commit type → changelog section)
# Use null to skip a type in changelog
sections:
feat: Features
fix: Fixes
docs: Documentation
refactor: Refactor
perf: Performance
test: Tests
chore: null
# Commit message format
commit:
message: "chore: release v{version}"
# Tag format
tag:
prefix: v # Results in v1.0.0
sign: false
# Additional files to include in release commit
include:
- README.md
- package.json
```
## Dry-Run Mode ## Dry-Run Mode
When `--dry-run` is specified: When `--dry-run` is specified:
1. Show all changes since last tag
2. Show proposed version bump (current → new)
3. Show draft changelog entries (EN and CN)
4. Show files that would be modified
5. Do NOT make any actual changes
Output format:
``` ```
=== DRY RUN MODE === === DRY RUN MODE ===
Last tag: v0.6.1 Project detected:
Proposed version: v0.7.0 Version file: package.json (1.2.3)
Changelogs: CHANGELOG.md (en), CHANGELOG.zh.md (zh)
Last tag: v1.2.3
Proposed version: v1.3.0
Changes detected: Changes detected:
- feat: new skill baoyu-foo added feat: add user authentication
- fix: baoyu-bar timeout issue feat: support oauth2 login
- docs: updated README fix: memory leak in pool
Changelog preview (EN): Changelog preview (en):
## 0.7.0 - 2026-01-17 ## 1.3.0 - 2026-01-22
### Features ### Features
- `baoyu-foo`: new skill for ... - Add user authentication module
### Fixes - Support OAuth2 login
- `baoyu-bar`: fixed timeout issue ### Fixes
- Fix memory leak in connection pool
README updates needed: Yes/No Changelog preview (zh):
(If yes, show proposed changes) ## 1.3.0 - 2026-01-22
### 新功能
- 新增用户认证模块
- 支持 OAuth2 登录
### 修复
- 修复连接池内存泄漏问题
Files to modify: Files to modify:
- README.md (if updates needed) - package.json
- README.zh.md (if updates needed) - CHANGELOG.md
- CHANGELOG.md - CHANGELOG.zh.md
- CHANGELOG.zh.md
- .claude-plugin/marketplace.json
No changes made. Run without --dry-run to execute. No changes made. Run without --dry-run to execute.
``` ```
@@ -202,16 +320,16 @@ No changes made. Run without --dry-run to execute.
/release-skills # Auto-detect version bump /release-skills # Auto-detect version bump
/release-skills --dry-run # Preview only /release-skills --dry-run # Preview only
/release-skills --minor # Force minor bump /release-skills --minor # Force minor bump
/release-skills --patch # Force patch bump
/release-skills --major # Force major bump (with confirmation) /release-skills --major # Force major bump (with confirmation)
``` ```
## Post-Release Reminder ## When to Use
After successful release, remind user: Trigger this skill when user requests:
``` - "release", "发布", "create release", "new version", "新版本"
Release v{NEW_VERSION} created locally. - "bump version", "update version", "更新版本"
- "prepare release"
- "push to remote" (with uncommitted changes)
To publish: **Important**: If user says "just push" or "直接 push" with uncommitted changes, STILL follow all steps above first.
git push origin main
git push origin v{NEW_VERSION}
```
+4
View File
@@ -6,6 +6,7 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
lerna-debug.log* lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html) # Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
@@ -145,3 +146,6 @@ tests-data/
.baoyu-skills/ .baoyu-skills/
x-to-markdown/ x-to-markdown/
xhs-images/ xhs-images/
url-to-markdown/
cover-image/
slide-deck/
+48
View File
@@ -2,6 +2,54 @@
English | [中文](./CHANGELOG.zh.md) English | [中文](./CHANGELOG.zh.md)
## 1.15.2 - 2026-01-23
### Documentation
- `release-skills`: comprehensive SKILL.md rewrite—adds multi-language changelog support, .releaserc.yml configuration, dry-run mode, language detection rules, and section title translations for 7 languages.
## 1.15.1 - 2026-01-22
### Refactor
- `baoyu-xhs-images`: restructures reference documents into modular architecture—reorganizes scattered files into `config/` (settings), `elements/` (visual building blocks), `presets/` (style definitions), and `workflows/` (process guides) directories for improved maintainability.
## 1.15.0 - 2026-01-22
### Features
- `baoyu-xhs-images`: adds user preferences support via EXTEND.md—configure watermark (content, position, opacity), preferred style, preferred layout, and custom styles. New Step 0 checks for preferences at project (`.baoyu-skills/`) or user (`~/.baoyu-skills/`) level with first-time setup flow.
### Documentation
- `baoyu-xhs-images`: adds three reference documents—`preferences-schema.md` (YAML schema), `watermark-guide.md` (position and opacity guide), `first-time-setup.md` (setup flow).
## 1.14.0 - 2026-01-22
### Fixes
- `baoyu-post-to-x`: improves video ready detection for more reliable video posting.
### Documentation
- `baoyu-slide-deck`: comprehensive SKILL.md enhancement—adds slide count guidance (recommended 8-25, max 30), audience guidelines table with audience-specific principles, style selection principles with content-type recommendations, layout selection tips with common mistakes to avoid, visual hierarchy principles, content density guidelines (McKinsey-style high-density principles), color selection guide, typography principles with font recommendations (English and Chinese fonts with multilingual pairing), and visual elements reference (backgrounds, typography treatments, geometric accents).
## 1.13.0 - 2026-01-21
### Features
- `baoyu-url-to-markdown`: new utility skill for fetching any URL via Chrome CDP and converting to clean markdown. Supports two capture modes—auto (immediate capture on page load) and wait (user-controlled capture for login-required pages).
### Improvements
- `baoyu-xhs-images`: updates style recommendations—replaces `tech` references with `notion` and `chalkboard` for technical and educational content.
## 1.12.0 - 2026-01-21
### Refactor
- `baoyu-post-to-x`: extracts shared utilities to `x-utils.ts`—consolidates Chrome detection, CDP connection, clipboard operations, and helper functions from `x-article.ts`, `x-browser.ts`, `x-quote.ts`, and `x-video.ts` into a single reusable module.
## 1.11.0 - 2026-01-21
### Features
- `baoyu-image-gen`: new AI SDK-based image generation skill using official OpenAI and Google APIs. Supports text-to-image, reference images (Google multimodal), aspect ratios, and quality presets (`normal`, `2k`). Auto-detects provider based on available API keys.
- `baoyu-slide-deck`: adds Layout Gallery with 24 layout types—10 slide-specific layouts (`title-hero`, `quote-callout`, `key-stat`, `split-screen`, `icon-grid`, `two-columns`, `three-columns`, `image-caption`, `agenda`, `bullet-list`) and 14 infographic-derived layouts (`linear-progression`, `binary-comparison`, `comparison-matrix`, `hierarchical-layers`, `hub-spoke`, `bento-grid`, `funnel`, `dashboard`, `venn-diagram`, `circular-flow`, `winding-roadmap`, `tree-branching`, `iceberg`, `bridge`).
### Documentation
- `README.md`, `README.zh.md`: adds baoyu-image-gen documentation with usage examples, options table, and environment variables; adds Environment Configuration section for API key setup.
## 1.10.0 - 2026-01-21 ## 1.10.0 - 2026-01-21
### Features ### Features
+48
View File
@@ -2,6 +2,54 @@
[English](./CHANGELOG.md) | 中文 [English](./CHANGELOG.md) | 中文
## 1.15.2 - 2026-01-23
### 文档
- `release-skills`:SKILL.md 全面重写——新增多语言 changelog 支持、.releaserc.yml 配置文件、dry-run 模式、语言检测规则、7 种语言的章节标题翻译。
## 1.15.1 - 2026-01-22
### 重构
- `baoyu-xhs-images`:参考文档模块化重构——将分散的文件整理为 `config/`(配置设置)、`elements/`(视觉构建块)、`presets/`(风格预设)、`workflows/`(流程指南)四个目录,提升可维护性。
## 1.15.0 - 2026-01-22
### 新功能
- `baoyu-xhs-images`:新增用户偏好设置支持(通过 EXTEND.md 配置)——可设置水印(内容、位置、透明度)、首选风格、首选布局和自定义风格。新增 Step 0 检查项目级(`.baoyu-skills/`)或用户级(`~/.baoyu-skills/`)偏好设置,首次使用时引导设置。
### 文档
- `baoyu-xhs-images`:新增三个参考文档——`preferences-schema.md`YAML 配置模式)、`watermark-guide.md`(水印位置和透明度指南)、`first-time-setup.md`(首次设置流程)。
## 1.14.0 - 2026-01-22
### 修复
- `baoyu-post-to-x`:改进视频就绪检测,提升视频发布稳定性。
### 文档
- `baoyu-slide-deck`:SKILL.md 全面增强——新增幻灯片数量指南(推荐 8-25 张,最多 30 张)、受众指南表格及各受众特定原则、风格选择原则与内容类型推荐、布局选择技巧与常见错误提示、视觉层次原则、内容密度指南(麦肯锡风格高密度原则)、配色选择指南、字体排版原则与字体推荐(中英文字体及多语言搭配方案)、视觉元素参考(背景处理、字体处理、几何装饰)。
## 1.13.0 - 2026-01-21
### 新功能
- `baoyu-url-to-markdown`:新增 URL 转 Markdown 工具技能,通过 Chrome CDP 抓取任意网页并转换为干净的 Markdown 格式。支持两种抓取模式——自动模式(页面加载后立即抓取)和等待模式(用户控制抓取时机,适用于需要登录的页面)。
### 改进
- `baoyu-xhs-images`:更新风格推荐——将 `tech` 风格引用替换为 `notion``chalkboard`,用于技术和教育内容。
## 1.12.0 - 2026-01-21
### 重构
- `baoyu-post-to-x`:提取公共工具函数到 `x-utils.ts`——将 `x-article.ts``x-browser.ts``x-quote.ts``x-video.ts` 中重复的 Chrome 检测、CDP 连接、剪贴板操作等功能整合为统一的可复用模块。
## 1.11.0 - 2026-01-21
### 新功能
- `baoyu-image-gen`:新增基于 AI SDK 的图像生成技能,使用官方 OpenAI 和 Google API。支持文生图、参考图(Google 多模态)、宽高比和质量预设(`normal``2k`)。根据可用的 API 密钥自动选择服务商。
- `baoyu-slide-deck`:新增布局库(Layout Gallery),包含 24 种布局类型——10 种幻灯片专用布局(`title-hero` 标题主图、`quote-callout` 引用突出、`key-stat` 关键数据、`split-screen` 分屏、`icon-grid` 图标网格、`two-columns` 双栏、`three-columns` 三栏、`image-caption` 图片说明、`agenda` 议程、`bullet-list` 要点列表)和 14 种信息图衍生布局(`linear-progression` 线性流程、`binary-comparison` 二元对比、`comparison-matrix` 对比矩阵、`hierarchical-layers` 层级、`hub-spoke` 中心辐射、`bento-grid` 便当盒、`funnel` 漏斗、`dashboard` 仪表盘、`venn-diagram` 韦恩图、`circular-flow` 循环流程、`winding-roadmap` 蜿蜒路线图、`tree-branching` 树状分支、`iceberg` 冰山、`bridge` 桥接)。
### 文档
- `README.md``README.zh.md`:新增 baoyu-image-gen 文档,包含用法示例、选项表和环境变量说明;新增环境配置章节,介绍 API 密钥设置方法。
## 1.10.0 - 2026-01-21 ## 1.10.0 - 2026-01-21
### 新功能 ### 新功能
+119 -3
View File
@@ -14,7 +14,7 @@ Skills shared by Baoyu for improving daily work efficiency with Claude Code.
### Quick Install (Recommended) ### Quick Install (Recommended)
```bash ```bash
npx add-skill jimliu/baoyu-skills npx skills add jimliu/baoyu-skills
``` ```
### Register as Plugin Marketplace ### Register as Plugin Marketplace
@@ -54,8 +54,8 @@ Simply tell Claude Code:
| Plugin | Description | Skills | | Plugin | Description | Skills |
|--------|-------------|--------| |--------|-------------|--------|
| **content-skills** | Content generation and publishing | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat) | | **content-skills** | Content generation and publishing | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat) |
| **ai-generation-skills** | AI-powered generation backends | [danger-gemini-web](#baoyu-danger-gemini-web) | | **ai-generation-skills** | AI-powered generation backends | [image-gen](#baoyu-image-gen), [danger-gemini-web](#baoyu-danger-gemini-web) |
| **utility-skills** | Utility tools for content processing | [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image) | | **utility-skills** | Utility tools for content processing | [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image) |
## Update Skills ## Update Skills
@@ -515,6 +515,55 @@ Prerequisites: Google Chrome installed. First run requires QR code login (sessio
AI-powered generation backends. AI-powered generation backends.
#### baoyu-image-gen
AI SDK-based image generation using official OpenAI and Google APIs. Supports text-to-image, reference images, aspect ratios, and quality presets.
```bash
# Basic generation (auto-detect provider)
/baoyu-image-gen --prompt "A cute cat" --image cat.png
# With aspect ratio
/baoyu-image-gen --prompt "A landscape" --image landscape.png --ar 16:9
# High quality (2k)
/baoyu-image-gen --prompt "A banner" --image banner.png --quality 2k
# Specific provider
/baoyu-image-gen --prompt "A cat" --image cat.png --provider openai
# With reference images (Google multimodal only)
/baoyu-image-gen --prompt "Make it blue" --image out.png --ref source.png
```
**Options**:
| Option | Description |
|--------|-------------|
| `--prompt`, `-p` | Prompt text |
| `--promptfiles` | Read prompt from files (concatenated) |
| `--image` | Output image path (required) |
| `--provider` | `google` or `openai` (default: google) |
| `--model`, `-m` | Model ID |
| `--ar` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
| `--size` | Size (e.g., `1024x1024`) |
| `--quality` | `normal` or `2k` (default: normal) |
| `--ref` | Reference images (Google multimodal only) |
**Environment Variables** (see [Environment Configuration](#environment-configuration) for setup):
| Variable | Description | Default |
|----------|-------------|---------|
| `OPENAI_API_KEY` | OpenAI API key | - |
| `GOOGLE_API_KEY` | Google API key | - |
| `OPENAI_IMAGE_MODEL` | OpenAI model | `gpt-image-1.5` |
| `GOOGLE_IMAGE_MODEL` | Google model | `gemini-3-pro-image-preview` |
| `OPENAI_BASE_URL` | Custom OpenAI endpoint | - |
| `GOOGLE_BASE_URL` | Custom Google endpoint | - |
**Provider Auto-Selection**:
1. If `--provider` specified → use it
2. If only one API key available → use that provider
3. If both available → default to Google
#### baoyu-danger-gemini-web #### baoyu-danger-gemini-web
Interacts with Gemini Web to generate text and images. Interacts with Gemini Web to generate text and images.
@@ -537,6 +586,35 @@ Interacts with Gemini Web to generate text and images.
Utility tools for content processing. Utility tools for content processing.
#### baoyu-url-to-markdown
Fetch any URL via Chrome CDP and convert to clean markdown. Supports two capture modes for different scenarios.
```bash
# Auto mode (default) - capture when page loads
/baoyu-url-to-markdown https://example.com/article
# Wait mode - for login-required pages
/baoyu-url-to-markdown https://example.com/private --wait
# Save to specific file
/baoyu-url-to-markdown https://example.com/article -o output.md
```
**Capture Modes**:
| Mode | Description | Best For |
|------|-------------|----------|
| Auto (default) | Captures immediately after page load | Public pages, static content |
| Wait (`--wait`) | Waits for user signal before capture | Login-required, dynamic content |
**Options**:
| Option | Description |
|--------|-------------|
| `<url>` | URL to fetch |
| `-o <path>` | Output file path |
| `--wait` | Wait for user signal before capturing |
| `--timeout <ms>` | Page load timeout (default: 30000) |
#### baoyu-danger-x-to-markdown #### baoyu-danger-x-to-markdown
Converts X (Twitter) content to markdown format. Supports tweet threads and X Articles. Converts X (Twitter) content to markdown format. Supports tweet threads and X Articles.
@@ -568,6 +646,44 @@ Compress images to reduce file size while maintaining quality.
/baoyu-compress-image path/to/images/ --quality 80 /baoyu-compress-image path/to/images/ --quality 80
``` ```
## Environment Configuration
Some skills require API keys or custom configuration. Environment variables can be set in `.env` files:
**Load Priority** (higher priority overrides lower):
1. CLI environment variables (e.g., `OPENAI_API_KEY=xxx /baoyu-image-gen ...`)
2. `process.env` (system environment)
3. `<cwd>/.baoyu-skills/.env` (project-level)
4. `~/.baoyu-skills/.env` (user-level)
**Setup**:
```bash
# Create user-level config directory
mkdir -p ~/.baoyu-skills
# Create .env file
cat > ~/.baoyu-skills/.env << 'EOF'
# OpenAI
OPENAI_API_KEY=sk-xxx
OPENAI_IMAGE_MODEL=gpt-image-1.5
# OPENAI_BASE_URL=https://api.openai.com/v1
# Google
GOOGLE_API_KEY=xxx
GOOGLE_IMAGE_MODEL=gemini-3-pro-image-preview
# GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta
EOF
```
**Project-level config** (for team sharing):
```bash
mkdir -p .baoyu-skills
# Add .baoyu-skills/.env to .gitignore to avoid committing secrets
echo ".baoyu-skills/.env" >> .gitignore
```
## Customization ## Customization
All skills support customization via `EXTEND.md` files. Create an extension file to override default styles, add custom configurations, or define your own presets. All skills support customization via `EXTEND.md` files. Create an extension file to override default styles, add custom configurations, or define your own presets.
+119 -3
View File
@@ -14,7 +14,7 @@
### 快速安装(推荐) ### 快速安装(推荐)
```bash ```bash
npx add-skill jimliu/baoyu-skills npx skills add jimliu/baoyu-skills
``` ```
### 注册插件市场 ### 注册插件市场
@@ -54,8 +54,8 @@ npx add-skill jimliu/baoyu-skills
| 插件 | 说明 | 包含技能 | | 插件 | 说明 | 包含技能 |
|------|------|----------| |------|------|----------|
| **content-skills** | 内容生成和发布 | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat) | | **content-skills** | 内容生成和发布 | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat) |
| **ai-generation-skills** | AI 生成后端 | [danger-gemini-web](#baoyu-danger-gemini-web) | | **ai-generation-skills** | AI 生成后端 | [image-gen](#baoyu-image-gen), [danger-gemini-web](#baoyu-danger-gemini-web) |
| **utility-skills** | 内容处理工具 | [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image) | | **utility-skills** | 内容处理工具 | [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image) |
## 更新技能 ## 更新技能
@@ -515,6 +515,55 @@ npx add-skill jimliu/baoyu-skills
AI 驱动的生成后端。 AI 驱动的生成后端。
#### baoyu-image-gen
基于 AI SDK 的图像生成,使用官方 OpenAI 和 Google API。支持文生图、参考图、宽高比和质量预设。
```bash
# 基础生成(自动检测服务商)
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png
# 指定宽高比
/baoyu-image-gen --prompt "风景图" --image landscape.png --ar 16:9
# 高质量(2k 分辨率)
/baoyu-image-gen --prompt "横幅图" --image banner.png --quality 2k
# 指定服务商
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider openai
# 带参考图(仅 Google 多模态支持)
/baoyu-image-gen --prompt "把它变成蓝色" --image out.png --ref source.png
```
**选项**
| 选项 | 说明 |
|------|------|
| `--prompt`, `-p` | 提示词文本 |
| `--promptfiles` | 从文件读取提示词(多文件拼接) |
| `--image` | 输出图片路径(必需) |
| `--provider` | `google``openai`(默认:google |
| `--model`, `-m` | 模型 ID |
| `--ar` | 宽高比(如 `16:9``1:1``4:3` |
| `--size` | 尺寸(如 `1024x1024` |
| `--quality` | `normal``2k`(默认:normal |
| `--ref` | 参考图片(仅 Google 多模态支持) |
**环境变量**(配置方法见[环境配置](#环境配置)):
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `OPENAI_API_KEY` | OpenAI API 密钥 | - |
| `GOOGLE_API_KEY` | Google API 密钥 | - |
| `OPENAI_IMAGE_MODEL` | OpenAI 模型 | `gpt-image-1.5` |
| `GOOGLE_IMAGE_MODEL` | Google 模型 | `gemini-3-pro-image-preview` |
| `OPENAI_BASE_URL` | 自定义 OpenAI 端点 | - |
| `GOOGLE_BASE_URL` | 自定义 Google 端点 | - |
**服务商自动选择**
1. 如果指定了 `--provider` → 使用指定的
2. 如果只有一个 API 密钥 → 使用对应服务商
3. 如果两个都有 → 默认使用 Google
#### baoyu-danger-gemini-web #### baoyu-danger-gemini-web
与 Gemini Web 交互,生成文本和图片。 与 Gemini Web 交互,生成文本和图片。
@@ -537,6 +586,35 @@ AI 驱动的生成后端。
内容处理工具。 内容处理工具。
#### baoyu-url-to-markdown
通过 Chrome CDP 抓取任意 URL 并转换为干净的 Markdown。支持两种抓取模式,适应不同场景。
```bash
# 自动模式(默认)- 页面加载后立即抓取
/baoyu-url-to-markdown https://example.com/article
# 等待模式 - 适用于需要登录的页面
/baoyu-url-to-markdown https://example.com/private --wait
# 保存到指定文件
/baoyu-url-to-markdown https://example.com/article -o output.md
```
**抓取模式**
| 模式 | 说明 | 适用场景 |
|------|------|----------|
| 自动(默认) | 页面加载后立即抓取 | 公开页面、静态内容 |
| 等待(`--wait`) | 等待用户信号后抓取 | 需登录页面、动态内容 |
**选项**
| 选项 | 说明 |
|------|------|
| `<url>` | 要抓取的 URL |
| `-o <path>` | 输出文件路径 |
| `--wait` | 等待用户信号后抓取 |
| `--timeout <ms>` | 页面加载超时(默认:30000) |
#### baoyu-danger-x-to-markdown #### baoyu-danger-x-to-markdown
将 X (Twitter) 内容转换为 markdown 格式。支持推文串和 X 文章。 将 X (Twitter) 内容转换为 markdown 格式。支持推文串和 X 文章。
@@ -568,6 +646,44 @@ AI 驱动的生成后端。
/baoyu-compress-image path/to/images/ --quality 80 /baoyu-compress-image path/to/images/ --quality 80
``` ```
## 环境配置
部分技能需要 API 密钥或自定义配置。环境变量可以在 `.env` 文件中设置:
**加载优先级**(高优先级覆盖低优先级):
1. 命令行环境变量(如 `OPENAI_API_KEY=xxx /baoyu-image-gen ...`
2. `process.env`(系统环境变量)
3. `<cwd>/.baoyu-skills/.env`(项目级)
4. `~/.baoyu-skills/.env`(用户级)
**配置方法**
```bash
# 创建用户级配置目录
mkdir -p ~/.baoyu-skills
# 创建 .env 文件
cat > ~/.baoyu-skills/.env << 'EOF'
# OpenAI
OPENAI_API_KEY=sk-xxx
OPENAI_IMAGE_MODEL=gpt-image-1.5
# OPENAI_BASE_URL=https://api.openai.com/v1
# Google
GOOGLE_API_KEY=xxx
GOOGLE_IMAGE_MODEL=gemini-3-pro-image-preview
# GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta
EOF
```
**项目级配置**(团队共享):
```bash
mkdir -p .baoyu-skills
# 将 .baoyu-skills/.env 添加到 .gitignore 避免提交密钥
echo ".baoyu-skills/.env" >> .gitignore
```
## 自定义扩展 ## 自定义扩展
所有技能支持通过 `EXTEND.md` 文件自定义。创建扩展文件可覆盖默认样式、添加自定义配置或定义个人预设。 所有技能支持通过 `EXTEND.md` 文件自定义。创建扩展文件可覆盖默认样式、添加自定义配置或定义个人预设。
+219
View File
@@ -0,0 +1,219 @@
---
name: baoyu-image-gen
description: AI SDK-based image generation using official OpenAI and Google APIs. Supports text-to-image, reference images, aspect ratios, and quality presets.
---
# Image Generation (AI SDK)
Official API-based image generation via AI SDK. Supports OpenAI (DALL-E, GPT Image) and Google (Imagen, Gemini multimodal).
## Script Directory
**Important**: All scripts are located in the `scripts/` subdirectory of this skill.
**Agent Execution Instructions**:
1. Determine this SKILL.md file's directory path as `SKILL_DIR`
2. Script path = `${SKILL_DIR}/scripts/<script-name>.ts`
3. Replace all `${SKILL_DIR}` in this document with the actual path
**Script Reference**:
| Script | Purpose |
|--------|---------|
| `scripts/main.ts` | CLI entry point for image generation |
## Quick Start
```bash
# Basic generation (auto-detect provider)
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image cat.png
# With aspect ratio
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A landscape" --image landscape.png --ar 16:9
# High quality (2k)
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image cat.png --quality 2k
# Specific provider
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image cat.png --provider openai
# From prompt files
npx -y bun ${SKILL_DIR}/scripts/main.ts --promptfiles system.md content.md --image out.png
# With reference images (Google multimodal only)
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Make blue" --image out.png --ref source.png
```
## Commands
### Basic Image Generation
```bash
# Generate with prompt
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A sunset over mountains" --image sunset.png
# Shorthand
npx -y bun ${SKILL_DIR}/scripts/main.ts -p "A cute robot" --image robot.png
```
### Aspect Ratios
```bash
# Common ratios: 1:1, 16:9, 9:16, 4:3, 3:4, 2.35:1
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A portrait" --image portrait.png --ar 3:4
# Or specify exact size
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Banner" --image banner.png --size 1792x1024
```
### Reference Images (Google Multimodal)
```bash
# Image editing with reference
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Make it blue" --image blue.png --ref original.png
# Multiple references
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Combine these styles" --image out.png --ref a.png b.png
```
### Quality Presets
```bash
# Normal quality (default)
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image cat.png --quality normal
# High quality (2k resolution)
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image cat.png --quality 2k
```
### Output Formats
```bash
# Plain output (prints saved path)
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image cat.png
# JSON output
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image cat.png --json
```
## Options
| Option | Description |
|--------|-------------|
| `--prompt <text>`, `-p` | Prompt text |
| `--promptfiles <files...>` | Read prompt from files (concatenated) |
| `--image <path>` | Output image path (required) |
| `--provider google\|openai` | Force provider (default: google) |
| `--model <id>`, `-m` | Model ID |
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
| `--size <WxH>` | Size (e.g., `1024x1024`) |
| `--quality normal\|2k` | Quality preset (default: normal) |
| `--ref <files...>` | Reference images (Google multimodal only) |
| `--n <count>` | Number of images |
| `--json` | JSON output |
| `--help`, `-h` | Show help |
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `OPENAI_API_KEY` | OpenAI API key | - |
| `GOOGLE_API_KEY` | Google API key | - |
| `OPENAI_IMAGE_MODEL` | OpenAI model | `gpt-image-1.5` |
| `GOOGLE_IMAGE_MODEL` | Google model | `gemini-3-pro-image-preview` |
| `OPENAI_BASE_URL` | Custom OpenAI endpoint | - |
| `GOOGLE_BASE_URL` | Custom Google endpoint | - |
**Load Priority**: CLI args > `process.env` > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
## Provider & Model Strategy
### Auto-Selection
1. If `--provider` specified → use it
2. If only one API key available → use that provider
3. If both available → default to Google (multimodal LLMs more versatile)
### API Selection by Model Type
| Model Category | API Function | Example Models |
|----------------|--------------|----------------|
| Google Multimodal | `generateText` | `gemini-2.0-flash-exp-image-generation` |
| Google Imagen | `experimental_generateImage` | `imagen-3.0-generate-002` |
| OpenAI | `experimental_generateImage` | `gpt-image-1`, `dall-e-3` |
### Available Models
**Google**:
- `gemini-3-pro-image-preview` - Default, multimodal generation
- `gemini-2.0-flash-exp-image-generation` - Gemini 2.0 Flash
- `imagen-3.0-generate-002` - Imagen 3
**OpenAI**:
- `gpt-image-1.5` - Default, GPT Image 1.5
- `gpt-image-1` - GPT Image 1
- `dall-e-3` - DALL-E 3
## Quality Presets
| Preset | OpenAI | Google | Use Case |
|--------|--------|--------|----------|
| `normal` | 1024x1024 | Default | Covers, illustrations |
| `2k` | 2048x2048 | "2048px" in prompt | Infographics, slides |
## Aspect Ratio Handling
- **Multimodal LLMs**: Embedded in prompt (e.g., `"... aspect ratio 16:9"`)
- **Image-only models**: Uses `aspectRatio` or `size` parameter
- **Common ratios**: 1:1, 16:9, 9:16, 4:3, 3:4, 2.35:1
## Examples
### Generate Cover Image
```bash
npx -y bun ${SKILL_DIR}/scripts/main.ts \
--prompt "A minimalist tech illustration with blue gradients" \
--image cover.png --ar 2.35:1 --quality 2k
```
### Generate Social Media Post
```bash
npx -y bun ${SKILL_DIR}/scripts/main.ts \
--prompt "Instagram post about coffee" \
--image post.png --ar 1:1
```
### Edit Image with Reference
```bash
npx -y bun ${SKILL_DIR}/scripts/main.ts \
--prompt "Change the background to sunset" \
--image edited.png --ref original.png --provider google
```
### Batch Generation from Prompt File
```bash
# Create prompt file with detailed instructions
npx -y bun ${SKILL_DIR}/scripts/main.ts \
--promptfiles style-guide.md scene-description.md \
--image scene.png
```
## Error Handling
- **Missing API key**: Clear error with setup instructions
- **Generation failure**: Auto-retry once, then error
- **Invalid aspect ratio**: Warning, proceed with default
- **Reference images with image-only model**: Warning, ignore refs
## Extension Support
Custom configurations via EXTEND.md.
**Check paths** (priority order):
1. `.baoyu-skills/baoyu-image-gen/EXTEND.md` (project)
2. `~/.baoyu-skills/baoyu-image-gen/EXTEND.md` (user)
If found, load before workflow. Extension content overrides defaults.
+576
View File
@@ -0,0 +1,576 @@
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { homedir } from "node:os";
import { mkdir, readFile, writeFile } from "node:fs/promises";
type Provider = "google" | "openai";
type Quality = "normal" | "2k";
type CliArgs = {
prompt: string | null;
promptFiles: string[];
imagePath: string | null;
provider: Provider | null;
model: string | null;
aspectRatio: string | null;
size: string | null;
quality: Quality;
referenceImages: string[];
n: number;
json: boolean;
help: boolean;
};
const GOOGLE_MULTIMODAL_MODELS = [
"gemini-3-pro-image-preview",
"gemini-2.0-flash-exp-image-generation",
"gemini-2.5-flash-preview-native-audio-dialog",
];
const GOOGLE_IMAGEN_MODELS = ["imagen-3.0-generate-002", "imagen-3.0-generate-001"];
const OPENAI_IMAGE_MODELS = ["gpt-image-1.5", "gpt-image-1", "dall-e-3", "dall-e-2"];
function printUsage(): void {
console.log(`Usage:
npx -y bun scripts/main.ts --prompt "A cat" --image cat.png
npx -y bun scripts/main.ts --prompt "A landscape" --image landscape.png --ar 16:9
npx -y bun scripts/main.ts --promptfiles system.md content.md --image out.png
Options:
-p, --prompt <text> Prompt text
--promptfiles <files...> Read prompt from files (concatenated)
--image <path> Output image path (required)
--provider google|openai Force provider (auto-detect by default)
-m, --model <id> Model ID
--ar <ratio> Aspect ratio (e.g., 16:9, 1:1, 4:3)
--size <WxH> Size (e.g., 1024x1024)
--quality normal|2k Quality preset (default: normal)
--ref <files...> Reference images (Google multimodal only)
--n <count> Number of images (default: 1)
--json JSON output
-h, --help Show help
Environment variables:
OPENAI_API_KEY OpenAI API key
GOOGLE_API_KEY Google API key
OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-1.5)
GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image-preview)
OPENAI_BASE_URL Custom OpenAI endpoint
GOOGLE_BASE_URL Custom Google endpoint
Env file load order: CLI args > process.env > <cwd>/.baoyu-skills/.env > ~/.baoyu-skills/.env`);
}
function parseArgs(argv: string[]): CliArgs {
const out: CliArgs = {
prompt: null,
promptFiles: [],
imagePath: null,
provider: null,
model: null,
aspectRatio: null,
size: null,
quality: "normal",
referenceImages: [],
n: 1,
json: false,
help: false,
};
const positional: string[] = [];
const takeMany = (i: number): { items: string[]; next: number } => {
const items: string[] = [];
let j = i + 1;
while (j < argv.length) {
const v = argv[j]!;
if (v.startsWith("-")) break;
items.push(v);
j++;
}
return { items, next: j - 1 };
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
if (a === "--help" || a === "-h") {
out.help = true;
continue;
}
if (a === "--json") {
out.json = true;
continue;
}
if (a === "--prompt" || a === "-p") {
const v = argv[++i];
if (!v) throw new Error(`Missing value for ${a}`);
out.prompt = v;
continue;
}
if (a === "--promptfiles") {
const { items, next } = takeMany(i);
if (items.length === 0) throw new Error("Missing files for --promptfiles");
out.promptFiles.push(...items);
i = next;
continue;
}
if (a === "--image") {
const v = argv[++i];
if (!v) throw new Error("Missing value for --image");
out.imagePath = v;
continue;
}
if (a === "--provider") {
const v = argv[++i];
if (v !== "google" && v !== "openai") throw new Error(`Invalid provider: ${v}`);
out.provider = v;
continue;
}
if (a === "--model" || a === "-m") {
const v = argv[++i];
if (!v) throw new Error(`Missing value for ${a}`);
out.model = v;
continue;
}
if (a === "--ar") {
const v = argv[++i];
if (!v) throw new Error("Missing value for --ar");
out.aspectRatio = v;
continue;
}
if (a === "--size") {
const v = argv[++i];
if (!v) throw new Error("Missing value for --size");
out.size = v;
continue;
}
if (a === "--quality") {
const v = argv[++i];
if (v !== "normal" && v !== "2k") throw new Error(`Invalid quality: ${v}`);
out.quality = v;
continue;
}
if (a === "--ref" || a === "--reference") {
const { items, next } = takeMany(i);
if (items.length === 0) throw new Error(`Missing files for ${a}`);
out.referenceImages.push(...items);
i = next;
continue;
}
if (a === "--n") {
const v = argv[++i];
if (!v) throw new Error("Missing value for --n");
out.n = parseInt(v, 10);
if (isNaN(out.n) || out.n < 1) throw new Error(`Invalid count: ${v}`);
continue;
}
if (a.startsWith("-")) {
throw new Error(`Unknown option: ${a}`);
}
positional.push(a);
}
if (!out.prompt && out.promptFiles.length === 0 && positional.length > 0) {
out.prompt = positional.join(" ");
}
return out;
}
async function loadEnvFile(p: string): Promise<Record<string, string>> {
try {
const content = await readFile(p, "utf8");
const env: Record<string, string> = {};
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const idx = trimmed.indexOf("=");
if (idx === -1) continue;
const key = trimmed.slice(0, idx).trim();
let val = trimmed.slice(idx + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
env[key] = val;
}
return env;
} catch {
return {};
}
}
async function loadEnv(): Promise<void> {
const home = homedir();
const cwd = process.cwd();
const homeEnv = await loadEnvFile(path.join(home, ".baoyu-skills", ".env"));
const cwdEnv = await loadEnvFile(path.join(cwd, ".baoyu-skills", ".env"));
for (const [k, v] of Object.entries(homeEnv)) {
if (!process.env[k]) process.env[k] = v;
}
for (const [k, v] of Object.entries(cwdEnv)) {
if (!process.env[k]) process.env[k] = v;
}
}
async function readPromptFromFiles(files: string[]): Promise<string> {
const parts: string[] = [];
for (const f of files) {
parts.push(await readFile(f, "utf8"));
}
return parts.join("\n\n");
}
async function readPromptFromStdin(): Promise<string | null> {
if (process.stdin.isTTY) return null;
try {
const t = await Bun.stdin.text();
const v = t.trim();
return v.length > 0 ? v : null;
} catch {
return null;
}
}
function normalizeOutputImagePath(p: string): string {
const full = path.resolve(p);
const ext = path.extname(full);
if (ext) return full;
return `${full}.png`;
}
function detectProvider(args: CliArgs): Provider {
if (args.provider) return args.provider;
const hasGoogle = !!process.env.GOOGLE_API_KEY;
const hasOpenai = !!process.env.OPENAI_API_KEY;
if (hasGoogle && !hasOpenai) return "google";
if (hasOpenai && !hasGoogle) return "openai";
if (hasGoogle && hasOpenai) return "google";
throw new Error(
"No API key found. Set GOOGLE_API_KEY or OPENAI_API_KEY.\n" +
"Create ~/.baoyu-skills/.env or <cwd>/.baoyu-skills/.env with your keys."
);
}
function getDefaultModel(provider: Provider): string {
if (provider === "google") {
return process.env.GOOGLE_IMAGE_MODEL || "gemini-3-pro-image-preview";
}
return process.env.OPENAI_IMAGE_MODEL || "gpt-image-1.5";
}
function isGoogleMultimodal(model: string): boolean {
return GOOGLE_MULTIMODAL_MODELS.some((m) => model.includes(m));
}
function isGoogleImagen(model: string): boolean {
return GOOGLE_IMAGEN_MODELS.some((m) => model.includes(m));
}
function buildPromptWithAspect(prompt: string, ar: string | null, quality: Quality): string {
let result = prompt;
if (ar) {
result += ` Aspect ratio: ${ar}.`;
}
if (quality === "2k") {
result += " High resolution 2048px.";
}
return result;
}
function parseAspectRatio(ar: string): { width: number; height: number } | null {
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
if (!match) return null;
const w = parseFloat(match[1]!);
const h = parseFloat(match[2]!);
if (w <= 0 || h <= 0) return null;
return { width: w, height: h };
}
function getOpenAISize(ar: string | null, quality: Quality): string {
const base = quality === "2k" ? 2048 : 1024;
if (!ar) return `${base}x${base}`;
const parsed = parseAspectRatio(ar);
if (!parsed) return `${base}x${base}`;
const ratio = parsed.width / parsed.height;
if (Math.abs(ratio - 1) < 0.1) return `${base}x${base}`;
if (ratio > 1.5) return quality === "2k" ? "2048x1024" : "1792x1024";
if (ratio < 0.67) return quality === "2k" ? "1024x2048" : "1024x1792";
return `${base}x${base}`;
}
async function readImageAsBase64(p: string): Promise<{ data: string; mimeType: string }> {
const buf = await readFile(p);
const ext = path.extname(p).toLowerCase();
let mimeType = "image/png";
if (ext === ".jpg" || ext === ".jpeg") mimeType = "image/jpeg";
else if (ext === ".gif") mimeType = "image/gif";
else if (ext === ".webp") mimeType = "image/webp";
return { data: buf.toString("base64"), mimeType };
}
async function generateWithGoogleMultimodal(
prompt: string,
model: string,
args: CliArgs
): Promise<Uint8Array> {
const { generateText } = await import("ai");
const { createGoogleGenerativeAI } = await import("@ai-sdk/google");
const google = createGoogleGenerativeAI({
apiKey: process.env.GOOGLE_API_KEY,
baseURL: process.env.GOOGLE_BASE_URL,
});
const fullPrompt = buildPromptWithAspect(prompt, args.aspectRatio, args.quality);
const messages: any[] = [];
const content: any[] = [];
for (const refPath of args.referenceImages) {
const { data, mimeType } = await readImageAsBase64(refPath);
content.push({ type: "image", image: data, mimeType });
}
content.push({ type: "text", text: fullPrompt });
messages.push({ role: "user", content });
const result = await generateText({
model: google(model, { useSearchGrounding: false }),
messages,
providerOptions: {
google: {
responseModalities: ["TEXT", "IMAGE"],
},
},
});
const files = (result as any).files;
if (!files || files.length === 0) {
const expRes = (result as any).response?.body?.candidates?.[0]?.content?.parts;
if (expRes) {
for (const part of expRes) {
if (part.inlineData?.data) {
return Uint8Array.from(Buffer.from(part.inlineData.data, "base64"));
}
}
}
throw new Error("No image in response");
}
const img = files[0];
if (img.uint8Array) return img.uint8Array;
if (img.base64) return Uint8Array.from(Buffer.from(img.base64, "base64"));
throw new Error("Cannot extract image data");
}
async function generateWithGoogleImagen(
prompt: string,
model: string,
args: CliArgs
): Promise<Uint8Array> {
const { experimental_generateImage: generateImage } = await import("ai");
const { createGoogleGenerativeAI } = await import("@ai-sdk/google");
const google = createGoogleGenerativeAI({
apiKey: process.env.GOOGLE_API_KEY,
baseURL: process.env.GOOGLE_BASE_URL,
});
const fullPrompt = buildPromptWithAspect(prompt, args.aspectRatio, args.quality);
const result = await generateImage({
model: google.image(model),
prompt: fullPrompt,
n: args.n,
aspectRatio: args.aspectRatio || undefined,
});
const img = result.images[0];
if (!img) throw new Error("No image in response");
if (img.uint8Array) return img.uint8Array;
if (img.base64) return Uint8Array.from(Buffer.from(img.base64, "base64"));
throw new Error("Cannot extract image data");
}
async function generateWithOpenAI(
prompt: string,
model: string,
args: CliArgs
): Promise<Uint8Array> {
const baseURL = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1";
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) throw new Error("OPENAI_API_KEY is required");
const size = args.size || getOpenAISize(args.aspectRatio, args.quality);
const body: Record<string, any> = {
model,
prompt,
size,
};
if (model.includes("dall-e-3")) {
body.quality = args.quality === "2k" ? "hd" : "standard";
}
const res = await fetch(`${baseURL}/images/generations`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`OpenAI API error: ${err}`);
}
const result = (await res.json()) as { data: Array<{ url?: string; b64_json?: string }> };
const img = result.data[0];
if (img?.b64_json) {
return Uint8Array.from(Buffer.from(img.b64_json, "base64"));
}
if (img?.url) {
const imgRes = await fetch(img.url);
if (!imgRes.ok) throw new Error("Failed to download image");
const buf = await imgRes.arrayBuffer();
return new Uint8Array(buf);
}
throw new Error("No image in response");
}
async function generate(
provider: Provider,
model: string,
prompt: string,
args: CliArgs
): Promise<Uint8Array> {
if (provider === "google") {
if (isGoogleMultimodal(model)) {
return generateWithGoogleMultimodal(prompt, model, args);
}
if (isGoogleImagen(model)) {
if (args.referenceImages.length > 0) {
console.error("Warning: Reference images not supported with Imagen models, ignoring.");
}
return generateWithGoogleImagen(prompt, model, args);
}
return generateWithGoogleMultimodal(prompt, model, args);
}
if (args.referenceImages.length > 0) {
console.error("Warning: Reference images not supported with OpenAI, ignoring.");
}
return generateWithOpenAI(prompt, model, args);
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printUsage();
return;
}
await loadEnv();
let prompt: string | null = args.prompt;
if (!prompt && args.promptFiles.length > 0) prompt = await readPromptFromFiles(args.promptFiles);
if (!prompt) prompt = await readPromptFromStdin();
if (!prompt) {
console.error("Error: Prompt is required");
printUsage();
process.exitCode = 1;
return;
}
if (!args.imagePath) {
console.error("Error: --image is required");
printUsage();
process.exitCode = 1;
return;
}
const provider = detectProvider(args);
const model = args.model || getDefaultModel(provider);
const outputPath = normalizeOutputImagePath(args.imagePath);
let imageData: Uint8Array;
let retried = false;
while (true) {
try {
imageData = await generate(provider, model, prompt, args);
break;
} catch (e) {
if (!retried) {
retried = true;
console.error("Generation failed, retrying...");
continue;
}
throw e;
}
}
const dir = path.dirname(outputPath);
await mkdir(dir, { recursive: true });
await writeFile(outputPath, imageData);
if (args.json) {
console.log(
JSON.stringify(
{
savedImage: outputPath,
provider,
model,
prompt: prompt.slice(0, 200),
},
null,
2
)
);
} else {
console.log(outputPath);
}
}
main().catch((e) => {
const msg = e instanceof Error ? e.message : String(e);
console.error(msg);
process.exit(1);
});
+23
View File
@@ -21,6 +21,7 @@ Post content, images, videos, and long-form articles to X using real Chrome brow
|--------|---------| |--------|---------|
| `scripts/x-browser.ts` | Regular posts (text + images) | | `scripts/x-browser.ts` | Regular posts (text + images) |
| `scripts/x-video.ts` | Video posts (text + video) | | `scripts/x-video.ts` | Video posts (text + video) |
| `scripts/x-quote.ts` | Quote tweet with comment |
| `scripts/x-article.ts` | Long-form article publishing (Markdown) | | `scripts/x-article.ts` | Long-form article publishing (Markdown) |
| `scripts/md-to-html.ts` | Markdown → HTML conversion | | `scripts/md-to-html.ts` | Markdown → HTML conversion |
| `scripts/copy-to-clipboard.ts` | Copy content to clipboard | | `scripts/copy-to-clipboard.ts` | Copy content to clipboard |
@@ -91,6 +92,28 @@ npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Amazing content" --video ./demo.mp4
--- ---
## Quote Tweets
Quote an existing tweet with your comment - a way to share content while giving credit to the original creator.
```bash
# Preview mode (doesn't post)
npx -y bun ${SKILL_DIR}/scripts/x-quote.ts https://x.com/user/status/123456789 "Great insight!"
# Actually post
npx -y bun ${SKILL_DIR}/scripts/x-quote.ts https://x.com/user/status/123456789 "I agree!" --submit
```
**Parameters**:
| Parameter | Description |
|-----------|-------------|
| `<tweet-url>` | URL of the tweet to quote (positional argument) |
| `<comment>` | Your comment text (positional argument, optional) |
| `--submit` | Actually post (default: preview only) |
| `--profile <dir>` | Custom Chrome profile directory |
---
## X Articles ## X Articles
Long-form Markdown articles (requires X Premium). Long-form Markdown articles (requires X Premium).
+15 -161
View File
@@ -1,12 +1,23 @@
import { spawn, spawnSync } from 'node:child_process'; import { spawn } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises'; import { mkdir, writeFile } from 'node:fs/promises';
import net from 'node:net';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import process from 'node:process'; import process from 'node:process';
import { parseMarkdown } from './md-to-html.js'; import { parseMarkdown } from './md-to-html.js';
import {
CHROME_CANDIDATES_BASIC,
CdpConnection,
copyHtmlToClipboard,
copyImageToClipboard,
findChromeExecutable,
getDefaultProfileDir,
getFreePort,
pasteFromClipboard,
sleep,
waitForChromeDebugPort,
} from './x-utils.js';
const X_ARTICLES_URL = 'https://x.com/compose/articles'; const X_ARTICLES_URL = 'https://x.com/compose/articles';
@@ -41,163 +52,6 @@ const I18N_SELECTORS = {
], ],
}; };
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 getScriptDir(): string {
return path.dirname(new URL(import.meta.url).pathname);
}
function copyImageToClipboard(imagePath: string): boolean {
const copyScript = path.join(getScriptDir(), '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 copyScript = path.join(getScriptDir(), 'copy-to-clipboard.ts');
const result = spawnSync('npx', ['-y', 'bun', copyScript, 'html', '--file', htmlPath], { stdio: 'inherit' });
return result.status === 0;
}
function pasteFromClipboard(targetApp?: string, retries = 3, delayMs = 500): boolean {
const pasteScript = path.join(getScriptDir(), 'paste-from-clipboard.ts');
const args = ['npx', '-y', 'bun', pasteScript, '--retries', String(retries), '--delay', String(delayMs)];
if (targetApp) {
args.push('--app', targetApp);
}
const result = spawnSync(args[0]!, args.slice(1), { stdio: 'inherit' });
return result.status === 0;
}
interface ArticleOptions { interface ArticleOptions {
markdownPath: string; markdownPath: string;
coverImage?: string; coverImage?: string;
@@ -225,7 +79,7 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
await writeFile(htmlPath, parsed.html, 'utf-8'); await writeFile(htmlPath, parsed.html, 'utf-8');
console.log(`[x-article] HTML saved to: ${htmlPath}`); console.log(`[x-article] HTML saved to: ${htmlPath}`);
const chromePath = options.chromePath ?? findChromeExecutable(); const chromePath = options.chromePath ?? findChromeExecutable(CHROME_CANDIDATES_BASIC);
if (!chromePath) throw new Error('Chrome not found'); if (!chromePath) throw new Error('Chrome not found');
await mkdir(profileDir, { recursive: true }); await mkdir(profileDir, { recursive: true });
@@ -246,7 +100,7 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
try { try {
const wsUrl = await waitForChromeDebugPort(port, 30_000); const wsUrl = await waitForChromeDebugPort(port, 30_000);
cdp = await CdpConnection.connect(wsUrl, 30_000); cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 30_000 });
// Get page target // Get page target
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets'); const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
+15 -197
View File
@@ -1,203 +1,21 @@
import { spawn, spawnSync } from 'node:child_process'; import { spawn } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import { mkdir } from 'node:fs/promises'; 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'; import process from 'node:process';
import {
CHROME_CANDIDATES_FULL,
CdpConnection,
copyImageToClipboard,
findChromeExecutable,
getDefaultProfileDir,
getFreePort,
pasteFromClipboard,
sleep,
waitForChromeDebugPort,
} from './x-utils.js';
const X_COMPOSE_URL = 'https://x.com/compose/post'; const X_COMPOSE_URL = 'https://x.com/compose/post';
function getScriptDir(): string {
return path.dirname(new URL(import.meta.url).pathname);
}
function copyImageToClipboard(imagePath: string): boolean {
const copyScript = path.join(getScriptDir(), 'copy-to-clipboard.ts');
const result = spawnSync('npx', ['-y', 'bun', copyScript, 'image', imagePath], { stdio: 'inherit' });
return result.status === 0;
}
function pasteFromClipboard(targetApp?: string, retries = 3, delayMs = 500): boolean {
const pasteScript = path.join(getScriptDir(), 'paste-from-clipboard.ts');
const args = ['npx', '-y', 'bun', pasteScript, '--retries', String(retries), '--delay', String(delayMs)];
if (targetApp) {
args.push('--app', targetApp);
}
const result = spawnSync(args[0]!, args.slice(1), { stdio: 'inherit' });
return result.status === 0;
}
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 { interface XBrowserOptions {
text?: string; text?: string;
images?: string[]; images?: string[];
@@ -210,7 +28,7 @@ interface XBrowserOptions {
export async function postToX(options: XBrowserOptions): Promise<void> { export async function postToX(options: XBrowserOptions): Promise<void> {
const { text, images = [], submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options; const { text, images = [], submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;
const chromePath = options.chromePath ?? findChromeExecutable(); const chromePath = options.chromePath ?? findChromeExecutable(CHROME_CANDIDATES_FULL);
if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.'); if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.');
await mkdir(profileDir, { recursive: true }); await mkdir(profileDir, { recursive: true });
@@ -231,8 +49,8 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
let cdp: CdpConnection | null = null; let cdp: CdpConnection | null = null;
try { try {
const wsUrl = await waitForChromeDebugPort(port, 30_000); const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
cdp = await CdpConnection.connect(wsUrl, 30_000); cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 15_000 });
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets'); 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')); let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('x.com'));
+247
View File
@@ -0,0 +1,247 @@
import { spawn } from 'node:child_process';
import { mkdir } from 'node:fs/promises';
import process from 'node:process';
import {
CHROME_CANDIDATES_FULL,
CdpConnection,
findChromeExecutable,
getDefaultProfileDir,
getFreePort,
sleep,
waitForChromeDebugPort,
} from './x-utils.js';
function extractTweetUrl(urlOrId: string): string | null {
// If it's already a full URL, normalize it
if (urlOrId.match(/(?:x\.com|twitter\.com)\/\w+\/status\/\d+/)) {
return urlOrId.replace(/twitter\.com/, 'x.com').split('?')[0];
}
return null;
}
interface QuoteOptions {
tweetUrl: string;
comment?: string;
submit?: boolean;
timeoutMs?: number;
profileDir?: string;
chromePath?: string;
}
export async function quotePost(options: QuoteOptions): Promise<void> {
const { tweetUrl, comment, submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;
const chromePath = options.chromePath ?? findChromeExecutable(CHROME_CANDIDATES_FULL);
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-quote] Launching Chrome (profile: ${profileDir})`);
console.log(`[x-quote] Opening tweet: ${tweetUrl}`);
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',
tweetUrl,
], { stdio: 'ignore' });
let cdp: CdpConnection | null = null;
try {
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 15_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: tweetUrl });
pageTarget = { targetId, url: tweetUrl, 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 });
console.log('[x-quote] Waiting for tweet to load...');
await sleep(3000);
// Wait for retweet button to appear (indicates tweet loaded and user logged in)
const waitForRetweetButton = 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="retweet"]')`,
returnByValue: true,
}, { sessionId });
if (result.result.value) return true;
await sleep(1000);
}
return false;
};
const retweetFound = await waitForRetweetButton();
if (!retweetFound) {
console.log('[x-quote] Tweet not found or not logged in. Please log in to X in the browser window.');
console.log('[x-quote] Waiting for login...');
const loggedIn = await waitForRetweetButton();
if (!loggedIn) throw new Error('Timed out waiting for tweet. Please log in first or check the tweet URL.');
}
// Click the retweet button
console.log('[x-quote] Clicking retweet button...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="retweet"]')?.click()`,
}, { sessionId });
await sleep(1000);
// Wait for and click the "Quote" option in the menu
console.log('[x-quote] Selecting quote option...');
const waitForQuoteOption = async (): Promise<boolean> => {
const start = Date.now();
while (Date.now() - start < 10_000) {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('[data-testid="Dropdown"] [role="menuitem"]:nth-child(2)')`,
returnByValue: true,
}, { sessionId });
if (result.result.value) return true;
await sleep(200);
}
return false;
};
const quoteOptionFound = await waitForQuoteOption();
if (!quoteOptionFound) {
throw new Error('Quote option not found. The menu may not have opened.');
}
// Click the quote option (second menu item)
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="Dropdown"] [role="menuitem"]:nth-child(2)')?.click()`,
}, { sessionId });
await sleep(2000);
// Wait for the quote compose dialog
console.log('[x-quote] Waiting for quote compose dialog...');
const waitForQuoteDialog = async (): Promise<boolean> => {
const start = Date.now();
while (Date.now() - start < 10_000) {
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(200);
}
return false;
};
const dialogFound = await waitForQuoteDialog();
if (!dialogFound) {
throw new Error('Quote compose dialog not found.');
}
// Type the comment if provided
if (comment) {
console.log('[x-quote] Typing comment...');
// Use CDP Input.insertText for more reliable text insertion
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetTextarea_0"]')?.focus()`,
}, { sessionId });
await sleep(200);
await cdp.send('Input.insertText', {
text: comment,
}, { sessionId });
await sleep(500);
}
if (submit) {
console.log('[x-quote] Submitting quote post...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,
}, { sessionId });
await sleep(2000);
console.log('[x-quote] Quote post submitted!');
} else {
console.log('[x-quote] Quote composed (preview mode). Add --submit to post.');
console.log('[x-quote] 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(`Quote a tweet on X (Twitter) using real Chrome browser
Usage:
npx -y bun x-quote.ts <tweet-url> [options] [comment]
Options:
--submit Actually post (default: preview only)
--profile <dir> Chrome profile directory
--help Show this help
Examples:
npx -y bun x-quote.ts https://x.com/user/status/123456789 "Great insight!"
npx -y bun x-quote.ts https://x.com/user/status/123456789 "I agree!" --submit
`);
process.exit(0);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) printUsage();
let tweetUrl: string | undefined;
let submit = false;
let profileDir: string | undefined;
const commentParts: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--submit') {
submit = true;
} else if (arg === '--profile' && args[i + 1]) {
profileDir = args[++i];
} else if (!arg.startsWith('-')) {
// First non-option argument is the tweet URL
if (!tweetUrl && arg.match(/(?:x\.com|twitter\.com)\/\w+\/status\/\d+/)) {
tweetUrl = extractTweetUrl(arg) ?? undefined;
} else {
commentParts.push(arg);
}
}
}
if (!tweetUrl) {
console.error('Error: Please provide a tweet URL.');
console.error('Example: npx -y bun x-quote.ts https://x.com/user/status/123456789 "Your comment"');
process.exit(1);
}
const comment = commentParts.join(' ').trim() || undefined;
await quotePost({ tweetUrl, comment, submit, profileDir });
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
+242
View File
@@ -0,0 +1,242 @@
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
export type PlatformCandidates = {
darwin?: string[];
win32?: string[];
default: string[];
};
export const CHROME_CANDIDATES_BASIC: PlatformCandidates = {
darwin: [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
],
win32: [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
],
default: [
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
],
};
export const CHROME_CANDIDATES_FULL: PlatformCandidates = {
darwin: [
'/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',
],
win32: [
'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',
],
default: [
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/snap/bin/chromium',
'/usr/bin/microsoft-edge',
],
};
function getCandidatesForPlatform(candidates: PlatformCandidates): string[] {
if (process.platform === 'darwin' && candidates.darwin?.length) return candidates.darwin;
if (process.platform === 'win32' && candidates.win32?.length) return candidates.win32;
return candidates.default;
}
export function findChromeExecutable(candidates: PlatformCandidates): string | undefined {
const override = process.env.X_BROWSER_CHROME_PATH?.trim();
if (override && fs.existsSync(override)) return override;
for (const candidate of getCandidatesForPlatform(candidates)) {
if (fs.existsSync(candidate)) return candidate;
}
return undefined;
}
export function getDefaultProfileDir(): string {
const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
return path.join(base, 'x-browser-profile');
}
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);
});
});
});
}
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;
}
export async function waitForChromeDebugPort(
port: number,
timeoutMs: number,
options?: { includeLastError?: boolean },
): 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);
}
if (options?.includeLastError && lastError) {
throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}
throw new Error('Chrome debug port not ready');
}
type PendingRequest = {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout> | null;
};
export class CdpConnection {
private ws: WebSocket;
private nextId = 0;
private pending = new Map<number, PendingRequest>();
private eventHandlers = new Map<string, Set<(params: unknown) => void>>();
private defaultTimeoutMs: number;
private constructor(ws: WebSocket, options?: { defaultTimeoutMs?: number }) {
this.ws = ws;
this.defaultTimeoutMs = options?.defaultTimeoutMs ?? 15_000;
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, options?: { defaultTimeoutMs?: 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, options);
}
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 ?? this.defaultTimeoutMs;
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 function getScriptDir(): string {
return path.dirname(new URL(import.meta.url).pathname);
}
function runBunScript(scriptPath: string, args: string[]): boolean {
const result = spawnSync('npx', ['-y', 'bun', scriptPath, ...args], { stdio: 'inherit' });
return result.status === 0;
}
export function copyImageToClipboard(imagePath: string): boolean {
const copyScript = path.join(getScriptDir(), 'copy-to-clipboard.ts');
return runBunScript(copyScript, ['image', imagePath]);
}
export function copyHtmlToClipboard(htmlPath: string): boolean {
const copyScript = path.join(getScriptDir(), 'copy-to-clipboard.ts');
return runBunScript(copyScript, ['html', '--file', htmlPath]);
}
export function pasteFromClipboard(targetApp?: string, retries = 3, delayMs = 500): boolean {
const pasteScript = path.join(getScriptDir(), 'paste-from-clipboard.ts');
const args = ['--retries', String(retries), '--delay', String(delayMs)];
if (targetApp) args.push('--app', targetApp);
return runBunScript(pasteScript, args);
}
+39 -186
View File
@@ -1,172 +1,20 @@
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import { mkdir } from 'node:fs/promises'; import { mkdir } from 'node:fs/promises';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import process from 'node:process'; import process from 'node:process';
import {
CHROME_CANDIDATES_FULL,
CdpConnection,
findChromeExecutable,
getDefaultProfileDir,
getFreePort,
sleep,
waitForChromeDebugPort,
} from './x-utils.js';
const X_COMPOSE_URL = 'https://x.com/compose/post'; 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 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;
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 XVideoOptions { interface XVideoOptions {
text?: string; text?: string;
videoPath: string; videoPath: string;
@@ -179,7 +27,7 @@ interface XVideoOptions {
export async function postVideoToX(options: XVideoOptions): Promise<void> { export async function postVideoToX(options: XVideoOptions): Promise<void> {
const { text, videoPath, submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options; const { text, videoPath, submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;
const chromePath = options.chromePath ?? findChromeExecutable(); const chromePath = options.chromePath ?? findChromeExecutable(CHROME_CANDIDATES_FULL);
if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.'); if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.');
if (!fs.existsSync(videoPath)) throw new Error(`Video not found: ${videoPath}`); if (!fs.existsSync(videoPath)) throw new Error(`Video not found: ${videoPath}`);
@@ -205,8 +53,8 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
let cdp: CdpConnection | null = null; let cdp: CdpConnection | null = null;
try { try {
const wsUrl = await waitForChromeDebugPort(port, 30_000); const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
cdp = await CdpConnection.connect(wsUrl, 30_000); cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 30_000 });
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets'); 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')); let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('x.com'));
@@ -264,24 +112,44 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
nodeId, nodeId,
files: [absVideoPath], files: [absVideoPath],
}, { sessionId }); }, { sessionId });
console.log('[x-video] Video file set, waiting for processing...'); console.log('[x-video] Video file set, uploading in background...');
// Wait for video to process // Wait a moment for upload to start, then type text while video processes
await sleep(2000);
// Type text while video uploads in background
if (text) {
console.log('[x-video] 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);
}
// Wait for video to finish processing by checking if tweet button is enabled
console.log('[x-video] Waiting for video processing...');
const waitForVideoReady = async (maxWaitMs = 180_000): Promise<boolean> => { const waitForVideoReady = async (maxWaitMs = 180_000): Promise<boolean> => {
const start = Date.now(); const start = Date.now();
let dots = 0; let dots = 0;
while (Date.now() - start < maxWaitMs) { while (Date.now() - start < maxWaitMs) {
const result = await cdp!.send<{ result: { value: { hasMedia: boolean; isProcessing: boolean } } }>('Runtime.evaluate', { const result = await cdp!.send<{ result: { value: { hasMedia: boolean; buttonEnabled: boolean } } }>('Runtime.evaluate', {
expression: `(() => { expression: `(() => {
const hasMedia = !!document.querySelector('[data-testid="attachments"] video, [data-testid="videoPlayer"], video'); const hasMedia = !!document.querySelector('[data-testid="attachments"] video, [data-testid="videoPlayer"], video');
const isProcessing = !!document.querySelector('[role="progressbar"], [data-testid="progressBar"]'); const tweetBtn = document.querySelector('[data-testid="tweetButton"]');
return { hasMedia, isProcessing }; const buttonEnabled = tweetBtn && !tweetBtn.disabled && tweetBtn.getAttribute('aria-disabled') !== 'true';
return { hasMedia, buttonEnabled };
})()`, })()`,
returnByValue: true, returnByValue: true,
}, { sessionId }); }, { sessionId });
const { hasMedia, isProcessing } = result.result.value; const { hasMedia, buttonEnabled } = result.result.value;
if (hasMedia && !isProcessing) { if (hasMedia && buttonEnabled) {
console.log(''); console.log('');
return true; return true;
} }
@@ -302,21 +170,6 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
console.log('[x-video] Video may still be processing. Please check browser window.'); console.log('[x-video] Video may still be processing. Please check browser window.');
} }
// Type text AFTER video is uploaded
if (text) {
console.log('[x-video] 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);
}
if (submit) { if (submit) {
console.log('[x-video] Submitting post...'); console.log('[x-video] Submitting post...');
await cdp.send('Runtime.evaluate', { await cdp.send('Runtime.evaluate', {
+270 -2
View File
@@ -41,11 +41,74 @@ Transform content into professional slide deck images with flexible style option
| `--style <name>` | Visual style (see Style Gallery) | | `--style <name>` | Visual style (see Style Gallery) |
| `--audience <type>` | Target audience: beginners, intermediate, experts, executives, general | | `--audience <type>` | Target audience: beginners, intermediate, experts, executives, general |
| `--lang <code>` | Output language (en, zh, ja, etc.) | | `--lang <code>` | Output language (en, zh, ja, etc.) |
| `--slides <number>` | Target slide count | | `--slides <number>` | Target slide count (recommended: 8-25, max: 30) |
| `--outline-only` | Generate outline only, skip image generation | | `--outline-only` | Generate outline only, skip image generation |
**Slide Count Guidance**:
| Content Length | Recommended Slides |
|----------------|-------------------|
| Short (< 1000 words) | 5-10 |
| Medium (1000-3000 words) | 10-18 |
| Long (3000-5000 words) | 15-25 |
| Very Long (> 5000 words) | 20-30 (consider splitting) |
Maximum 30 slides per deck. For longer content, split into multiple decks.
## Audience Guidelines
Design decisions should adapt to target audience. Use `--audience` to set.
| Audience | Content Density | Visual Style | Terminology | Slides |
|----------|-----------------|--------------|-------------|--------|
| `beginners` | Low | Friendly, illustrative | Plain language | 8-15 |
| `intermediate` | Medium | Balanced, structured | Some jargon OK | 10-20 |
| `experts` | High | Data-rich, precise | Technical terms | 12-25 |
| `executives` | Medium-High | Clean, impactful | Business language | 8-12 |
| `general` | Medium | Accessible, engaging | Minimal jargon | 10-18 |
### Audience-Specific Principles
**Beginners**:
- One concept per slide
- Visual metaphors over abstract diagrams
- Step-by-step progression
- Generous whitespace
**Experts**:
- Multiple data points per slide acceptable
- Technical diagrams with precise labels
- Assume domain knowledge
- Dense but organized information
**Executives**:
- Lead with insights, not data
- "So what?" on every slide
- Decision-enabling content
- Bottom-line upfront (BLUF)
## Style Gallery ## Style Gallery
### Style Selection Principles
**Content-First Approach**:
1. Analyze content topic, mood, and industry before selecting
2. Consider target audience expectations
3. Match style to subject matter (not personal preference)
**Quick Reference by Content Type**:
| Content Type | Recommended Styles |
|--------------|-------------------|
| Technical/Architecture | `blueprint`, `intuition-machine` |
| Educational/Tutorials | `sketch-notes`, `chalkboard` |
| Corporate/Business | `corporate`, `minimal` |
| Creative/Artistic | `vector-illustration`, `watercolor` |
| Product/SaaS | `notion`, `bold-editorial` |
| Scientific/Research | `scientific`, `editorial-infographic` |
**Note**: Full style specifications in `references/styles/<style>.md`
### Available Styles
| Style | Description | Best For | | Style | Description | Best For |
|-------|-------------|----------| |-------|-------------|----------|
| `blueprint` (Default) | Technical schematics, grid texture | Architecture, system design | | `blueprint` (Default) | Technical schematics, grid texture | Architecture, system design |
@@ -87,6 +150,72 @@ Transform content into professional slide deck images with flexible style option
| lifestyle, wellness, travel, artistic, natural | `watercolor` | | lifestyle, wellness, travel, artistic, natural | `watercolor` |
| Default | `blueprint` | | Default | `blueprint` |
## Layout Gallery
Optional layout hints for individual slides. Specify in outline's `// LAYOUT` section.
### Slide-Specific Layouts
| Layout | Description | Best For |
|--------|-------------|----------|
| `title-hero` | Large centered title + subtitle | Cover slides, section breaks |
| `quote-callout` | Featured quote with attribution | Testimonials, key insights |
| `key-stat` | Single large number as focal point | Impact statistics, metrics |
| `split-screen` | Half image, half text | Feature highlights, comparisons |
| `icon-grid` | Grid of icons with labels | Features, capabilities, benefits |
| `two-columns` | Content in balanced columns | Paired information, dual points |
| `three-columns` | Content in three columns | Triple comparisons, categories |
| `image-caption` | Full-bleed image + text overlay | Visual storytelling, emotional |
| `agenda` | Numbered list with highlights | Session overview, roadmap |
| `bullet-list` | Structured bullet points | Simple content, lists |
### Infographic-Derived Layouts
| Layout | Description | Best For |
|--------|-------------|----------|
| `linear-progression` | Sequential flow left-to-right | Timelines, step-by-step |
| `binary-comparison` | Side-by-side A vs B | Before/after, pros-cons |
| `comparison-matrix` | Multi-factor grid | Feature comparisons |
| `hierarchical-layers` | Pyramid or stacked levels | Priority, importance |
| `hub-spoke` | Central node with radiating items | Concept maps, ecosystems |
| `bento-grid` | Varied-size tiles | Overview, summary |
| `funnel` | Narrowing stages | Conversion, filtering |
| `dashboard` | Metrics with charts/numbers | KPIs, data display |
| `venn-diagram` | Overlapping circles | Relationships, intersections |
| `circular-flow` | Continuous cycle | Recurring processes |
| `winding-roadmap` | Curved path with milestones | Journey, timeline |
| `tree-branching` | Parent-child hierarchy | Org charts, taxonomies |
| `iceberg` | Visible vs hidden layers | Surface vs depth |
| `bridge` | Gap with connection | Problem-solution |
**Usage**: Add `Layout: <name>` in slide's `// LAYOUT` section to guide visual composition.
### Layout Selection Tips
**Match Layout to Content**:
| Content Type | Recommended Layouts |
|--------------|-------------------|
| Single narrative | `bullet-list`, `image-caption` |
| Two concepts | `split-screen`, `binary-comparison` |
| Three items | `three-columns`, `icon-grid` |
| Process/Steps | `linear-progression`, `winding-roadmap` |
| Data/Metrics | `dashboard`, `key-stat` |
| Relationships | `hub-spoke`, `venn-diagram` |
| Hierarchy | `hierarchical-layers`, `tree-branching` |
**Layout Flow Patterns**:
| Position | Recommended Layouts |
|----------|-------------------|
| Opening | `title-hero`, `agenda` |
| Middle | Content-specific layouts |
| Closing | `quote-callout`, `key-stat` |
**Common Mistakes to Avoid**:
- ✗ Using 3-column layout for 2 items (leaves columns empty)
- ✗ Stacking charts/tables below text (use side-by-side instead)
- ✗ Image layouts without actual images
- ✗ Quote layouts for emphasis (use only for real quotes with attribution)
## Design Philosophy ## Design Philosophy
This deck is designed for **reading and sharing**, not live presentation: This deck is designed for **reading and sharing**, not live presentation:
@@ -95,6 +224,142 @@ This deck is designed for **reading and sharing**, not live presentation:
- Include **all necessary context** within each slide - Include **all necessary context** within each slide
- Optimize for **social media sharing** and offline reading - Optimize for **social media sharing** and offline reading
### Visual Hierarchy Principles
| Principle | Description |
|-----------|-------------|
| Focal Point | ONE dominant element per slide draws attention first |
| Rule of Thirds | Position key elements at grid intersections |
| Z-Pattern | Guide eye: top-left → top-right → bottom-left → bottom-right |
| Size Contrast | Headlines 2-3x larger than body text |
| Breathing Room | Minimum 10% margin from all edges |
### Content Density
Professional presentations balance information density with clarity.
| Level | Description | Use When |
|-------|-------------|----------|
| High | Multiple data points, detailed charts, dense text | Expert audience, technical reviews, data-driven decisions |
| Medium | Key points with supporting details, moderate visuals | General business, mixed audiences |
| Low | One main idea, large visuals, minimal text | Beginners, keynotes, emotional impact |
**High-Density Principles** (McKinsey-style):
- Every element earns its space
- Data speaks louder than decoration
- Annotations explain insights, not describe data
- White space is strategic, not filler
- Information hierarchy guides the eye
**Density by Slide Type**:
| Slide Type | Recommended Density |
|------------|-------------------|
| Cover/Title | Low |
| Agenda/Overview | Medium |
| Content/Analysis | Medium-High |
| Data/Metrics | High |
| Quote/Impact | Low |
| Summary/Takeaway | Medium |
### Color Selection
**Content-First Approach**:
1. Analyze content topic, mood, and industry
2. Consider target audience expectations
3. Match palette to subject matter (not defaults)
4. Ensure strong contrast for readability
**Quick Palette Guide**:
| Content Type | Recommended Palettes |
|--------------|---------------------|
| Technical/Architecture | Blues, grays, blueprint tones |
| Educational/Friendly | Warm colors, earth tones |
| Corporate/Professional | Navy, gold, structured palettes |
| Creative/Artistic | Bold colors, unexpected combinations |
| Scientific/Medical | Clean whites, precise color coding |
**Note**: Full color specs in `references/styles/<style>.md`
### Typography Principles
| Element | Treatment |
|---------|-----------|
| Headlines | Bold, 2-3x body size, narrative style |
| Body Text | Regular weight, readable size |
| Captions | Smaller, lighter weight |
| Data Labels | Monospace for technical content |
| Emphasis | Use bold or color, not underlines |
### Font Recommendations
Fonts for AI-generated slides. Specify in prompts for consistent rendering.
**English Fonts**:
| Font | Style | Best For |
|------|-------|----------|
| Liter | Sans-serif, geometric | Modern, clean, technical |
| HedvigLettersSans | Sans-serif, distinctive | Brand-forward, creative |
| Oranienbaum | High-contrast serif | Elegant, classical |
| SortsMillGoudy | Classical serif | Traditional, readable |
| Coda | Round sans-serif | Friendly, approachable |
**Chinese Fonts**:
| Font | Style | Best For |
|------|-------|----------|
| MiSans | Modern sans-serif | Clean, versatile, screen-optimized |
| Noto Sans SC | Neutral sans-serif | Standard, multilingual |
| siyuanSongti | Refined Song typeface | Elegant, editorial |
| alimamashuheiti | Geometric sans-serif | Commercial, structured |
| LXGW Bright | Song-Kai hybrid | Warm, readable |
**Multilingual Pairing**:
| Use Case | English | Chinese |
|----------|---------|---------|
| Technical | Liter | MiSans |
| Editorial | Oranienbaum | siyuanSongti |
| Friendly | Coda | LXGW Bright |
| Corporate | HedvigLettersSans | alimamashuheiti |
### Consistency Requirements
| Element | Guideline |
|---------|-----------|
| Spacing | Consistent margins and padding throughout |
| Colors | Maximum 3-4 colors per slide, palette consistent across deck |
| Typography | Same font families and sizes for same content types |
| Visual Language | Repeat patterns, shapes, and treatments |
## Visual Elements Reference
Quick reference for visual treatments. Full specs in style definitions.
### Background Treatments
| Treatment | Description | Best For |
|-----------|-------------|----------|
| Solid color | Single background color | Clean, minimal |
| Split background | Two colors, diagonal or vertical | Contrast, sections |
| Gradient | Subtle vertical or diagonal fade | Modern, dynamic |
| Textured | Pattern or texture overlay | Character, style |
### Typography Treatments
| Treatment | Description | Best For |
|-----------|-------------|----------|
| Size contrast | 3-4x difference headline vs body | Impact, hierarchy |
| All-caps headers | Uppercase with letter spacing | Authority, structure |
| Monospace data | Fixed-width for numbers/code | Technical, precision |
| Hand-drawn | Organic, imperfect letterforms | Friendly, approachable |
### Geometric Accents
| Element | Description | Best For |
|---------|-------------|----------|
| Diagonal dividers | Angled section separators | Energy, movement |
| Corner brackets | L-shaped frames | Focus, framing |
| Circles/hexagons | Shape frames for images | Modern, tech |
| Underline accents | Thick lines under headers | Emphasis, hierarchy |
## File Management ## File Management
### Output Directory ### Output Directory
@@ -169,7 +434,10 @@ If `--outline-only`, stop here.
1. Read `references/base-prompt.md` 1. Read `references/base-prompt.md`
2. Combine with style instructions from outline 2. Combine with style instructions from outline
3. Add slide-specific content 3. Add slide-specific content
4. Save to `prompts/` directory 4. If `Layout:` specified in outline, include layout guidance in prompt:
- Reference layout characteristics for image composition
- Example: `Layout: hub-spoke` → "Central concept in middle with related items radiating outward"
5. Save to `prompts/` directory
### Step 5: Generate Images ### Step 5: Generate Images
@@ -67,6 +67,7 @@ Sub-headline: [supporting tagline]
[Detailed visual description - specific elements, composition, mood] [Detailed visual description - specific elements, composition, mood]
// LAYOUT // LAYOUT
Layout: [optional: layout name from gallery, e.g., title-hero]
[Composition, hierarchy, spatial arrangement] [Composition, hierarchy, spatial arrangement]
``` ```
@@ -93,6 +94,7 @@ Body:
[Detailed visual description] [Detailed visual description]
// LAYOUT // LAYOUT
Layout: [optional: layout name from gallery]
[Composition, hierarchy, spatial arrangement] [Composition, hierarchy, spatial arrangement]
``` ```
@@ -115,6 +117,7 @@ Body: [optional summary points or next steps]
[Visual that reinforces the core message] [Visual that reinforces the core message]
// LAYOUT // LAYOUT
Layout: [optional: layout name from gallery]
[Clean, impactful composition] [Clean, impactful composition]
``` ```
+169
View File
@@ -0,0 +1,169 @@
---
name: baoyu-url-to-markdown
description: Fetch any URL and convert to markdown using Chrome CDP. Supports two modes - auto-capture on page load, or wait for user signal (for pages requiring login). Use when user wants to save a webpage as markdown.
---
# URL to Markdown
Fetches any URL via Chrome CDP and converts HTML to clean markdown.
## Script Directory
**Important**: All scripts are located in the `scripts/` subdirectory of this skill.
**Agent Execution Instructions**:
1. Determine this SKILL.md file's directory path as `SKILL_DIR`
2. Script path = `${SKILL_DIR}/scripts/<script-name>.ts`
3. Replace all `${SKILL_DIR}` in this document with the actual path
**Script Reference**:
| Script | Purpose |
|--------|---------|
| `scripts/main.ts` | CLI entry point for URL fetching |
## Features
- Chrome CDP for full JavaScript rendering
- Two capture modes: auto or wait-for-user
- Clean markdown output with metadata
- Handles login-required pages via wait mode
## Usage
```bash
# Auto mode (default) - capture when page loads
npx -y bun ${SKILL_DIR}/scripts/main.ts <url>
# Wait mode - wait for user signal before capture
npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --wait
# Save to specific file
npx -y bun ${SKILL_DIR}/scripts/main.ts <url> -o output.md
```
## Options
| Option | Description |
|--------|-------------|
| `<url>` | URL to fetch |
| `-o <path>` | Output file path (default: auto-generated) |
| `--wait` | Wait for user signal before capturing |
| `--timeout <ms>` | Page load timeout (default: 30000) |
## Capture Modes
### Auto Mode (default)
Page loads → waits for network idle → captures immediately.
Best for:
- Public pages
- Static content
- No login required
### Wait Mode (`--wait`)
Page opens → user can interact (login, scroll, etc.) → user signals ready → captures.
Best for:
- Login-required pages
- Dynamic content needing interaction
- Pages with lazy loading
**Agent workflow for wait mode**:
1. Run script with `--wait` flag
2. Script outputs: `Page opened. Press Enter when ready to capture...`
3. Use `AskUserQuestion` to ask user if page is ready
4. When user confirms, send newline to stdin to trigger capture
## Output Format
```markdown
---
url: https://example.com/page
title: "Page Title"
description: "Meta description if available"
author: "Author if available"
published: "2024-01-01"
captured_at: "2024-01-15T10:30:00Z"
---
# Page Title
Converted markdown content...
```
## Mode Selection Guide
When user requests URL capture, help select appropriate mode:
**Suggest Auto Mode when**:
- URL is public (no login wall visible)
- Content appears static
- User doesn't mention login requirements
**Suggest Wait Mode when**:
- User mentions needing to log in
- Site known to require authentication
- User wants to scroll/interact before capture
- Content is behind paywall
**Ask user when unclear**:
```
The page may require login or interaction before capturing.
Which mode should I use?
1. Auto - Capture immediately when loaded
2. Wait - Wait for you to interact first
```
## Output Directory
Each capture creates a file organized by domain:
```
url-to-markdown/
└── <domain>/
└── <slug>.md
```
**Path Components**:
- `<domain>`: Site domain (e.g., `example.com`, `github.com`)
- `<slug>`: Generated from page title or URL path (kebab-case)
**Slug Generation**:
1. Extract from page title (preferred) or URL path
2. Convert to kebab-case, 2-6 words
3. Example: "Getting Started with React" → `getting-started-with-react`
**Conflict Resolution**:
If `url-to-markdown/<domain>/<slug>.md` already exists:
- Append timestamp: `<slug>-YYYYMMDD-HHMMSS.md`
- Example: `getting-started.md` exists → `getting-started-20260118-143052.md`
## Error Handling
| Error | Resolution |
|-------|------------|
| Chrome not found | Install Chrome or set `URL_CHROME_PATH` env |
| Page timeout | Increase `--timeout` value |
| Capture failed | Try wait mode for complex pages |
| Empty content | Page may need JS rendering time |
## Environment Variables
| Variable | Description |
|----------|-------------|
| `URL_CHROME_PATH` | Custom Chrome executable path |
| `URL_DATA_DIR` | Custom data directory |
| `URL_CHROME_PROFILE_DIR` | Custom Chrome profile directory |
## Extension Support
Custom configurations via EXTEND.md.
**Check paths** (priority order):
1. `.baoyu-skills/baoyu-url-to-markdown/EXTEND.md` (project)
2. `~/.baoyu-skills/baoyu-url-to-markdown/EXTEND.md` (user)
If found, load before workflow. Extension content overrides defaults.
+290
View File
@@ -0,0 +1,290 @@
import { spawn, type ChildProcess } from "node:child_process";
import fs from "node:fs";
import { mkdir } from "node:fs/promises";
import net from "node:net";
import process from "node:process";
import { resolveUrlToMarkdownChromeProfileDir } from "./paths.js";
import { CDP_CONNECT_TIMEOUT_MS, NETWORK_IDLE_TIMEOUT_MS } from "./constants.js";
type CdpSendOptions = { sessionId?: string; timeoutMs?: number };
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchWithTimeout(url: string, init: RequestInit & { timeoutMs?: number } = {}): Promise<Response> {
const { timeoutMs, ...rest } = init;
if (!timeoutMs || timeoutMs <= 0) return fetch(url, rest);
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), timeoutMs);
try {
return await fetch(url, { ...rest, signal: ctl.signal });
} finally {
clearTimeout(t);
}
}
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.id) {
const p = this.pending.get(msg.id);
if (p) {
this.pending.delete(msg.id);
if (p.timer) clearTimeout(p.timer);
if (msg.error?.message) p.reject(new Error(msg.error.message));
else p.resolve(msg.result);
}
} else if (msg.method) {
const handlers = this.eventHandlers.get(msg.method);
if (handlers) {
for (const h of handlers) h(msg.params);
}
}
} catch {}
});
this.ws.addEventListener("close", () => {
for (const [id, p] of this.pending.entries()) {
this.pending.delete(id);
if (p.timer) clearTimeout(p.timer);
p.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 t = setTimeout(() => reject(new Error("CDP connection timeout.")), timeoutMs);
ws.addEventListener("open", () => { clearTimeout(t); resolve(); });
ws.addEventListener("error", () => { clearTimeout(t); reject(new Error("CDP connection failed.")); });
});
return new CdpConnection(ws);
}
on(event: string, handler: (params: unknown) => void): void {
let handlers = this.eventHandlers.get(event);
if (!handlers) {
handlers = new Set();
this.eventHandlers.set(event, handlers);
}
handlers.add(handler);
}
off(event: string, handler: (params: unknown) => void): void {
this.eventHandlers.get(event)?.delete(handler);
}
async send<T = unknown>(method: string, params?: Record<string, unknown>, opts?: CdpSendOptions): Promise<T> {
const id = ++this.nextId;
const msg: Record<string, unknown> = { id, method };
if (params) msg.params = params;
if (opts?.sessionId) msg.sessionId = opts.sessionId;
const timeoutMs = opts?.timeoutMs ?? 15_000;
const out = await new Promise<unknown>((resolve, reject) => {
const t = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) : null;
this.pending.set(id, { resolve, reject, timer: t });
this.ws.send(JSON.stringify(msg));
});
return out as T;
}
close(): void {
try { this.ws.close(); } catch {}
}
}
export async function getFreePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const srv = net.createServer();
srv.unref();
srv.on("error", reject);
srv.listen(0, "127.0.0.1", () => {
const addr = srv.address();
if (!addr || typeof addr === "string") {
srv.close(() => reject(new Error("Unable to allocate a free TCP port.")));
return;
}
const port = addr.port;
srv.close((err) => (err ? reject(err) : resolve(port)));
});
});
}
export function findChromeExecutable(): string | null {
const override = process.env.URL_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 null;
}
export async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise<string> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const res = await fetchWithTimeout(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 5_000 });
if (!res.ok) throw new Error(`status=${res.status}`);
const j = (await res.json()) as { webSocketDebuggerUrl?: string };
if (j.webSocketDebuggerUrl) return j.webSocketDebuggerUrl;
} catch {}
await sleep(200);
}
throw new Error("Chrome debug port not ready");
}
export async function launchChrome(url: string, port: number, headless: boolean = false): Promise<ChildProcess> {
const chrome = findChromeExecutable();
if (!chrome) throw new Error("Chrome executable not found. Install Chrome or set URL_CHROME_PATH env.");
const profileDir = resolveUrlToMarkdownChromeProfileDir();
await mkdir(profileDir, { recursive: true });
const args = [
`--remote-debugging-port=${port}`,
`--user-data-dir=${profileDir}`,
"--no-first-run",
"--no-default-browser-check",
"--disable-popup-blocking",
];
if (headless) args.push("--headless=new");
args.push(url);
return spawn(chrome, args, { stdio: "ignore" });
}
export async function waitForNetworkIdle(cdp: CdpConnection, sessionId: string, timeoutMs: number = NETWORK_IDLE_TIMEOUT_MS): Promise<void> {
return new Promise((resolve) => {
let timer: ReturnType<typeof setTimeout> | null = null;
let pending = 0;
const cleanup = () => {
if (timer) clearTimeout(timer);
cdp.off("Network.requestWillBeSent", onRequest);
cdp.off("Network.loadingFinished", onFinish);
cdp.off("Network.loadingFailed", onFinish);
};
const done = () => { cleanup(); resolve(); };
const resetTimer = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(done, timeoutMs);
};
const onRequest = () => { pending++; resetTimer(); };
const onFinish = () => { pending = Math.max(0, pending - 1); if (pending <= 2) resetTimer(); };
cdp.on("Network.requestWillBeSent", onRequest);
cdp.on("Network.loadingFinished", onFinish);
cdp.on("Network.loadingFailed", onFinish);
resetTimer();
});
}
export async function waitForPageLoad(cdp: CdpConnection, sessionId: string, timeoutMs: number = 30_000): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
cdp.off("Page.loadEventFired", handler);
resolve();
}, timeoutMs);
const handler = () => {
clearTimeout(timer);
cdp.off("Page.loadEventFired", handler);
resolve();
};
cdp.on("Page.loadEventFired", handler);
});
}
export async function createTargetAndAttach(cdp: CdpConnection, url: string): Promise<{ targetId: string; sessionId: string }> {
const { targetId } = await cdp.send<{ targetId: string }>("Target.createTarget", { url });
const { sessionId } = await cdp.send<{ sessionId: string }>("Target.attachToTarget", { targetId, flatten: true });
await cdp.send("Network.enable", {}, { sessionId });
await cdp.send("Page.enable", {}, { sessionId });
return { targetId, sessionId };
}
export async function navigateAndWait(cdp: CdpConnection, sessionId: string, url: string, timeoutMs: number): Promise<void> {
const loadPromise = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("Page load timeout")), timeoutMs);
const handler = (params: unknown) => {
const p = params as { name?: string };
if (p.name === "load" || p.name === "DOMContentLoaded") {
clearTimeout(timer);
cdp.off("Page.lifecycleEvent", handler);
resolve();
}
};
cdp.on("Page.lifecycleEvent", handler);
});
await cdp.send("Page.navigate", { url }, { sessionId });
await loadPromise;
}
export async function evaluateScript<T>(cdp: CdpConnection, sessionId: string, expression: string, timeoutMs: number = 30_000): Promise<T> {
const result = await cdp.send<{ result: { value?: T; type?: string; description?: string } }>(
"Runtime.evaluate",
{ expression, returnByValue: true, awaitPromise: true },
{ sessionId, timeoutMs }
);
return result.result.value as T;
}
export async function autoScroll(cdp: CdpConnection, sessionId: string, steps: number = 8, waitMs: number = 600): Promise<void> {
let lastHeight = await evaluateScript<number>(cdp, sessionId, "document.body.scrollHeight");
for (let i = 0; i < steps; i++) {
await evaluateScript<void>(cdp, sessionId, "window.scrollTo(0, document.body.scrollHeight)");
await sleep(waitMs);
const newHeight = await evaluateScript<number>(cdp, sessionId, "document.body.scrollHeight");
if (newHeight === lastHeight) break;
lastHeight = newHeight;
}
await evaluateScript<void>(cdp, sessionId, "window.scrollTo(0, 0)");
}
export function killChrome(chrome: ChildProcess): void {
try { chrome.kill("SIGTERM"); } catch {}
setTimeout(() => {
if (!chrome.killed) {
try { chrome.kill("SIGKILL"); } catch {}
}
}, 2_000).unref?.();
}
@@ -0,0 +1,13 @@
import { resolveUrlToMarkdownChromeProfileDir } from "./paths.js";
export const DEFAULT_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
export const USER_DATA_DIR = resolveUrlToMarkdownChromeProfileDir();
export const DEFAULT_TIMEOUT_MS = 30_000;
export const CDP_CONNECT_TIMEOUT_MS = 15_000;
export const NETWORK_IDLE_TIMEOUT_MS = 1_500;
export const POST_LOAD_DELAY_MS = 800;
export const SCROLL_STEP_WAIT_MS = 600;
export const SCROLL_MAX_STEPS = 8;
@@ -0,0 +1,223 @@
export interface PageMetadata {
url: string;
title: string;
description?: string;
author?: string;
published?: string;
captured_at: string;
}
export interface ConversionResult {
metadata: PageMetadata;
markdown: string;
}
export const cleanupAndExtractScript = `
(function() {
const removeSelectors = [
'script', 'style', 'noscript', 'iframe', 'svg', 'canvas',
'header nav', 'footer', '.sidebar', '.nav', '.navigation',
'.advertisement', '.ad', '.ads', '.cookie-banner', '.popup',
'[role="banner"]', '[role="navigation"]', '[role="complementary"]'
];
for (const sel of removeSelectors) {
try {
document.querySelectorAll(sel).forEach(el => el.remove());
} catch {}
}
document.querySelectorAll('*').forEach(el => {
el.removeAttribute('style');
el.removeAttribute('onclick');
el.removeAttribute('onload');
el.removeAttribute('onerror');
});
const baseUrl = document.baseURI || location.href;
document.querySelectorAll('a[href]').forEach(a => {
try {
const href = a.getAttribute('href');
if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
a.setAttribute('href', new URL(href, baseUrl).href);
}
} catch {}
});
document.querySelectorAll('img[src]').forEach(img => {
try {
const src = img.getAttribute('src');
if (src) img.setAttribute('src', new URL(src, baseUrl).href);
} catch {}
});
const getMeta = (names) => {
for (const name of names) {
const el = document.querySelector(\`meta[name="\${name}"], meta[property="\${name}"]\`);
if (el) {
const content = el.getAttribute('content');
if (content) return content.trim();
}
}
return undefined;
};
const getTitle = () => {
const ogTitle = getMeta(['og:title', 'twitter:title']);
if (ogTitle) return ogTitle;
const h1 = document.querySelector('h1');
if (h1) return h1.textContent?.trim();
return document.title?.trim() || '';
};
const getPublished = () => {
const timeEl = document.querySelector('time[datetime]');
if (timeEl) return timeEl.getAttribute('datetime');
return getMeta(['article:published_time', 'datePublished', 'date']);
};
const main = document.querySelector('main, article, [role="main"], .main-content, .post-content, .article-content, .content');
const html = main ? main.innerHTML : document.body.innerHTML;
return {
title: getTitle(),
description: getMeta(['description', 'og:description', 'twitter:description']),
author: getMeta(['author', 'article:author', 'twitter:creator']),
published: getPublished(),
html: html
};
})()
`;
function decodeHtmlEntities(text: string): string {
return text
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'")
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)));
}
function stripTags(html: string): string {
return html.replace(/<[^>]+>/g, '');
}
function normalizeWhitespace(text: string): string {
return text.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
}
export function htmlToMarkdown(html: string): string {
let md = html;
md = md.replace(/<br\s*\/?>/gi, '\n');
md = md.replace(/<hr\s*\/?>/gi, '\n\n---\n\n');
md = md.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, (_, c) => `\n\n# ${stripTags(c).trim()}\n\n`);
md = md.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, (_, c) => `\n\n## ${stripTags(c).trim()}\n\n`);
md = md.replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, (_, c) => `\n\n### ${stripTags(c).trim()}\n\n`);
md = md.replace(/<h4[^>]*>([\s\S]*?)<\/h4>/gi, (_, c) => `\n\n#### ${stripTags(c).trim()}\n\n`);
md = md.replace(/<h5[^>]*>([\s\S]*?)<\/h5>/gi, (_, c) => `\n\n##### ${stripTags(c).trim()}\n\n`);
md = md.replace(/<h6[^>]*>([\s\S]*?)<\/h6>/gi, (_, c) => `\n\n###### ${stripTags(c).trim()}\n\n`);
md = md.replace(/<strong[^>]*>([\s\S]*?)<\/strong>/gi, (_, c) => `**${stripTags(c).trim()}**`);
md = md.replace(/<b[^>]*>([\s\S]*?)<\/b>/gi, (_, c) => `**${stripTags(c).trim()}**`);
md = md.replace(/<em[^>]*>([\s\S]*?)<\/em>/gi, (_, c) => `*${stripTags(c).trim()}*`);
md = md.replace(/<i[^>]*>([\s\S]*?)<\/i>/gi, (_, c) => `*${stripTags(c).trim()}*`);
md = md.replace(/<del[^>]*>([\s\S]*?)<\/del>/gi, (_, c) => `~~${stripTags(c).trim()}~~`);
md = md.replace(/<s[^>]*>([\s\S]*?)<\/s>/gi, (_, c) => `~~${stripTags(c).trim()}~~`);
md = md.replace(/<mark[^>]*>([\s\S]*?)<\/mark>/gi, (_, c) => `==${stripTags(c).trim()}==`);
md = md.replace(/<a[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (_, href, text) => {
const t = stripTags(text).trim();
if (!t || href.startsWith('javascript:')) return t;
return `[${t}](${href})`;
});
md = md.replace(/<img[^>]*src=["']([^"']+)["'][^>]*alt=["']([^"']*)["'][^>]*\/?>/gi, (_, src, alt) => `![${alt}](${src})`);
md = md.replace(/<img[^>]*alt=["']([^"']*)["'][^>]*src=["']([^"']+)["'][^>]*\/?>/gi, (_, alt, src) => `![${alt}](${src})`);
md = md.replace(/<img[^>]*src=["']([^"']+)["'][^>]*\/?>/gi, (_, src) => `![](${src})`);
md = md.replace(/<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, (_, code) => `\n\n\`\`\`\n${decodeHtmlEntities(stripTags(code)).trim()}\n\`\`\`\n\n`);
md = md.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, (_, code) => `\n\n\`\`\`\n${decodeHtmlEntities(stripTags(code)).trim()}\n\`\`\`\n\n`);
md = md.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, (_, code) => `\`${decodeHtmlEntities(stripTags(code)).trim()}\``);
md = md.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (_, content) => {
const lines = stripTags(content).trim().split('\n');
return '\n\n' + lines.map(l => `> ${l.trim()}`).join('\n') + '\n\n';
});
md = md.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, (_, items) => {
const lis = items.match(/<li[^>]*>([\s\S]*?)<\/li>/gi) || [];
const lines = lis.map(li => {
const content = li.replace(/<li[^>]*>([\s\S]*?)<\/li>/i, '$1');
return `- ${stripTags(content).trim()}`;
});
return '\n\n' + lines.join('\n') + '\n\n';
});
md = md.replace(/<ol[^>]*>([\s\S]*?)<\/ol>/gi, (_, items) => {
const lis = items.match(/<li[^>]*>([\s\S]*?)<\/li>/gi) || [];
const lines = lis.map((li, i) => {
const content = li.replace(/<li[^>]*>([\s\S]*?)<\/li>/i, '$1');
return `${i + 1}. ${stripTags(content).trim()}`;
});
return '\n\n' + lines.join('\n') + '\n\n';
});
md = md.replace(/<table[^>]*>([\s\S]*?)<\/table>/gi, (_, table) => {
const rows: string[][] = [];
const trMatches = table.match(/<tr[^>]*>([\s\S]*?)<\/tr>/gi) || [];
for (const tr of trMatches) {
const cells: string[] = [];
const cellMatches = tr.match(/<t[hd][^>]*>([\s\S]*?)<\/t[hd]>/gi) || [];
for (const cell of cellMatches) {
const content = cell.replace(/<t[hd][^>]*>([\s\S]*?)<\/t[hd]>/i, '$1');
cells.push(stripTags(content).trim().replace(/\|/g, '\\|'));
}
if (cells.length > 0) rows.push(cells);
}
if (rows.length === 0) return '';
const colCount = Math.max(...rows.map(r => r.length));
const normalizedRows = rows.map(r => {
while (r.length < colCount) r.push('');
return r;
});
const header = `| ${normalizedRows[0].join(' | ')} |`;
const sep = `| ${normalizedRows[0].map(() => '---').join(' | ')} |`;
const body = normalizedRows.slice(1).map(r => `| ${r.join(' | ')} |`).join('\n');
return '\n\n' + header + '\n' + sep + (body ? '\n' + body : '') + '\n\n';
});
md = md.replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, (_, c) => `\n\n${stripTags(c).trim()}\n\n`);
md = md.replace(/<div[^>]*>([\s\S]*?)<\/div>/gi, (_, c) => `\n${stripTags(c).trim()}\n`);
md = md.replace(/<span[^>]*>([\s\S]*?)<\/span>/gi, (_, c) => stripTags(c));
md = stripTags(md);
md = decodeHtmlEntities(md);
md = normalizeWhitespace(md);
return md;
}
export function formatMetadataYaml(meta: PageMetadata): string {
const lines = ['---'];
lines.push(`url: ${meta.url}`);
lines.push(`title: "${meta.title.replace(/"/g, '\\"')}"`);
if (meta.description) lines.push(`description: "${meta.description.replace(/"/g, '\\"')}"`);
if (meta.author) lines.push(`author: "${meta.author.replace(/"/g, '\\"')}"`);
if (meta.published) lines.push(`published: "${meta.published}"`);
lines.push(`captured_at: "${meta.captured_at}"`);
lines.push('---');
return lines.join('\n');
}
export function createMarkdownDocument(result: ConversionResult): string {
const yaml = formatMetadataYaml(result.metadata);
const titleRegex = new RegExp(`^#\\s+${result.metadata.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\n`, 'i');
const hasTitle = titleRegex.test(result.markdown);
const title = result.metadata.title && !hasTitle ? `\n\n# ${result.metadata.title}\n\n` : '\n\n';
return yaml + title + result.markdown;
}
@@ -0,0 +1,176 @@
import { createInterface } from "node:readline";
import { writeFile, mkdir, access } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { CdpConnection, getFreePort, launchChrome, waitForChromeDebugPort, waitForNetworkIdle, waitForPageLoad, autoScroll, evaluateScript, killChrome } from "./cdp.js";
import { cleanupAndExtractScript, htmlToMarkdown, createMarkdownDocument, type PageMetadata, type ConversionResult } from "./html-to-markdown.js";
import { resolveUrlToMarkdownDataDir } from "./paths.js";
import { DEFAULT_TIMEOUT_MS, CDP_CONNECT_TIMEOUT_MS, NETWORK_IDLE_TIMEOUT_MS, POST_LOAD_DELAY_MS, SCROLL_STEP_WAIT_MS, SCROLL_MAX_STEPS } from "./constants.js";
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
interface Args {
url: string;
output?: string;
wait: boolean;
timeout: number;
}
function parseArgs(argv: string[]): Args {
const args: Args = { url: "", wait: false, timeout: DEFAULT_TIMEOUT_MS };
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--wait" || arg === "-w") {
args.wait = true;
} else if (arg === "-o" || arg === "--output") {
args.output = argv[++i];
} else if (arg === "--timeout" || arg === "-t") {
args.timeout = parseInt(argv[++i], 10) || DEFAULT_TIMEOUT_MS;
} else if (!arg.startsWith("-") && !args.url) {
args.url = arg;
}
}
return args;
}
function generateSlug(title: string, url: string): string {
const text = title || new URL(url).pathname.replace(/\//g, "-");
return text
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 50) || "page";
}
function formatTimestamp(): string {
const now = new Date();
const pad = (n: number) => n.toString().padStart(2, "0");
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
}
async function generateOutputPath(url: string, title: string): Promise<string> {
const domain = new URL(url).hostname.replace(/^www\./, "");
const slug = generateSlug(title, url);
const dataDir = resolveUrlToMarkdownDataDir();
const basePath = path.join(dataDir, domain, `${slug}.md`);
if (!(await fileExists(basePath))) {
return basePath;
}
const timestampSlug = `${slug}-${formatTimestamp()}`;
return path.join(dataDir, domain, `${timestampSlug}.md`);
}
async function waitForUserSignal(): Promise<void> {
console.log("Page opened. Press Enter when ready to capture...");
const rl = createInterface({ input: process.stdin, output: process.stdout });
await new Promise<void>((resolve) => {
rl.once("line", () => { rl.close(); resolve(); });
});
}
async function captureUrl(args: Args): Promise<ConversionResult> {
const port = await getFreePort();
const chrome = await launchChrome(args.url, port, false);
let cdp: CdpConnection | null = null;
try {
const wsUrl = await waitForChromeDebugPort(port, 30_000);
cdp = await CdpConnection.connect(wsUrl, CDP_CONNECT_TIMEOUT_MS);
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; type: string; url: string }> }>("Target.getTargets");
const pageTarget = targets.targetInfos.find(t => t.type === "page" && t.url.startsWith("http"));
if (!pageTarget) throw new Error("No page target found");
const { sessionId } = await cdp.send<{ sessionId: string }>("Target.attachToTarget", { targetId: pageTarget.targetId, flatten: true });
await cdp.send("Network.enable", {}, { sessionId });
await cdp.send("Page.enable", {}, { sessionId });
if (args.wait) {
await waitForUserSignal();
} else {
console.log("Waiting for page to load...");
await Promise.race([
waitForPageLoad(cdp, sessionId, 15_000),
sleep(8_000)
]);
await waitForNetworkIdle(cdp, sessionId, NETWORK_IDLE_TIMEOUT_MS);
await sleep(POST_LOAD_DELAY_MS);
console.log("Scrolling to trigger lazy load...");
await autoScroll(cdp, sessionId, SCROLL_MAX_STEPS, SCROLL_STEP_WAIT_MS);
await sleep(POST_LOAD_DELAY_MS);
}
console.log("Capturing page content...");
const extracted = await evaluateScript<{ title: string; description?: string; author?: string; published?: string; html: string }>(
cdp, sessionId, cleanupAndExtractScript, args.timeout
);
const metadata: PageMetadata = {
url: args.url,
title: extracted.title || "",
description: extracted.description,
author: extracted.author,
published: extracted.published,
captured_at: new Date().toISOString()
};
const markdown = htmlToMarkdown(extracted.html);
return { metadata, markdown };
} finally {
if (cdp) {
try { await cdp.send("Browser.close", {}, { timeoutMs: 5_000 }); } catch {}
cdp.close();
}
killChrome(chrome);
}
}
async function main(): Promise<void> {
const args = parseArgs(process.argv);
if (!args.url) {
console.error("Usage: bun main.ts <url> [-o output.md] [--wait] [--timeout ms]");
process.exit(1);
}
try {
new URL(args.url);
} catch {
console.error(`Invalid URL: ${args.url}`);
process.exit(1);
}
console.log(`Fetching: ${args.url}`);
console.log(`Mode: ${args.wait ? "wait" : "auto"}`);
const result = await captureUrl(args);
const outputPath = args.output || await generateOutputPath(args.url, result.metadata.title);
const outputDir = path.dirname(outputPath);
await mkdir(outputDir, { recursive: true });
const document = createMarkdownDocument(result);
await writeFile(outputPath, document, "utf-8");
console.log(`Saved: ${outputPath}`);
console.log(`Title: ${result.metadata.title || "(no title)"}`);
}
main().catch((err) => {
console.error("Error:", err instanceof Error ? err.message : String(err));
process.exit(1);
});
@@ -0,0 +1,29 @@
import os from "node:os";
import path from "node:path";
import process from "node:process";
const APP_DATA_DIR = "baoyu-skills";
const URL_TO_MARKDOWN_DATA_DIR = "url-to-markdown";
const PROFILE_DIR_NAME = "chrome-profile";
export function resolveUserDataRoot(): string {
if (process.platform === "win32") {
return process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
}
if (process.platform === "darwin") {
return path.join(os.homedir(), "Library", "Application Support");
}
return process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share");
}
export function resolveUrlToMarkdownDataDir(): string {
const override = process.env.URL_DATA_DIR?.trim();
if (override) return path.resolve(override);
return path.join(process.cwd(), URL_TO_MARKDOWN_DATA_DIR);
}
export function resolveUrlToMarkdownChromeProfileDir(): string {
const override = process.env.URL_CHROME_PROFILE_DIR?.trim();
if (override) return path.resolve(override);
return path.join(resolveUserDataRoot(), APP_DATA_DIR, URL_TO_MARKDOWN_DATA_DIR, PROFILE_DIR_NAME);
}
+213 -97
View File
@@ -1,6 +1,6 @@
--- ---
name: baoyu-xhs-images 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". description: Generates Xiaohongshu (Little Red Book) infographic series with 9 visual styles and 6 layouts. Breaks content into 1-10 cartoon-style images optimized for XHS engagement. Use when user mentions "小红书图片", "XHS images", "RedNote infographics", "小红书种草", or wants social media infographics for Chinese platforms.
--- ---
# Xiaohongshu Infographic Series Generator # Xiaohongshu Infographic Series Generator
@@ -20,7 +20,7 @@ Break down complex content into eye-catching infographic series for Xiaohongshu
/baoyu-xhs-images posts/ai-future/article.md --layout dense /baoyu-xhs-images posts/ai-future/article.md --layout dense
# Combine style and layout # Combine style and layout
/baoyu-xhs-images posts/ai-future/article.md --style tech --layout list /baoyu-xhs-images posts/ai-future/article.md --style notion --layout list
# Direct content input # Direct content input
/baoyu-xhs-images /baoyu-xhs-images
@@ -61,7 +61,7 @@ Style × Layout can be freely combined. Example: `--style notion --layout dense`
| `notion` | Minimalist hand-drawn line art, intellectual | | `notion` | Minimalist hand-drawn line art, intellectual |
| `chalkboard` | Colorful chalk on black board, educational | | `chalkboard` | Colorful chalk on black board, educational |
Detailed style definitions: `references/styles/<style>.md` Detailed style definitions: `references/presets/<style>.md`
## Layout Gallery ## Layout Gallery
@@ -74,7 +74,7 @@ Detailed style definitions: `references/styles/<style>.md`
| `comparison` | Side-by-side contrast layout | | `comparison` | Side-by-side contrast layout |
| `flow` | Process and timeline layout (3-6 steps) | | `flow` | Process and timeline layout (3-6 steps) |
Detailed layout definitions: `references/layouts/<layout>.md` Detailed layout definitions: `references/elements/canvas.md`
## Auto Selection ## Auto Selection
@@ -90,6 +90,37 @@ Detailed layout definitions: `references/layouts/<layout>.md`
| Knowledge, concept, productivity, SaaS | `notion` | dense/list | | Knowledge, concept, productivity, SaaS | `notion` | dense/list |
| Education, tutorial, learning, teaching, classroom | `chalkboard` | balanced/dense | | Education, tutorial, learning, teaching, classroom | `chalkboard` | balanced/dense |
## Outline Strategies
Three differentiated outline strategies for different content goals:
### Strategy A: Story-Driven (故事驱动型)
| Aspect | Description |
|--------|-------------|
| **Concept** | Personal experience as main thread, emotional resonance first |
| **Features** | Start from pain point, show before/after change, strong authenticity |
| **Best for** | Reviews, personal shares, transformation stories |
| **Structure** | Hook → Problem → Discovery → Experience → Conclusion |
### Strategy B: Information-Dense (信息密集型)
| Aspect | Description |
|--------|-------------|
| **Concept** | Value-first, efficient information delivery |
| **Features** | Clear structure, explicit points, professional credibility |
| **Best for** | Tutorials, comparisons, product reviews, checklists |
| **Structure** | Core conclusion → Info card → Pros/Cons → Recommendation |
### Strategy C: Visual-First (视觉优先型)
| Aspect | Description |
|--------|-------------|
| **Concept** | Visual impact as core, minimal text |
| **Features** | Large images, atmospheric, instant appeal |
| **Best for** | High-aesthetic products, lifestyle, mood-based content |
| **Structure** | Hero image → Detail shots → Lifestyle scene → CTA |
## File Structure ## File Structure
Each session creates an independent directory named by content slug: Each session creates an independent directory named by content slug:
@@ -97,11 +128,11 @@ Each session creates an independent directory named by content slug:
``` ```
xhs-images/{topic-slug}/ xhs-images/{topic-slug}/
├── source-{slug}.{ext} # Source files (text, images, etc.) ├── source-{slug}.{ext} # Source files (text, images, etc.)
├── analysis.md # Deep analysis results ├── analysis.md # Deep analysis + questions asked
├── outline-style-[slug].md # Variant A (e.g., outline-style-tech.md) ├── outline-strategy-a.md # Strategy A: Story-driven
├── outline-style-[slug].md # Variant B (e.g., outline-style-notion.md) ├── outline-strategy-b.md # Strategy B: Information-dense
├── outline-style-[slug].md # Variant C (e.g., outline-style-minimal.md) ├── outline-strategy-c.md # Strategy C: Visual-first
├── outline.md # Final selected ├── outline.md # Final selected/merged outline
├── prompts/ ├── prompts/
│ ├── 01-cover-[slug].md │ ├── 01-cover-[slug].md
│ ├── 02-content-[slug].md │ ├── 02-content-[slug].md
@@ -127,6 +158,52 @@ Copy all sources with naming `source-{slug}.{ext}`:
## Workflow ## Workflow
### Progress Checklist
Copy and track progress:
```
XHS Infographic Progress:
- [ ] Step 0: Check preferences (EXTEND.md)
- [ ] Step 1: Analyze content → analysis.md
- [ ] Step 2: Confirmation 1 - Content understanding ⚠️ REQUIRED
- [ ] Step 3: Generate 3 outline + style variants
- [ ] Step 4: Confirmation 2 - Outline & style selection ⚠️ REQUIRED
- [ ] Step 5: Generate images (sequential)
- [ ] Step 6: Completion report
```
### Flow
```
Input → Analyze → [Confirm 1] → 3 Outlines → [Confirm 2: Outline + Style] → Generate → Complete
```
### Step 0: Check Preferences
**Check paths** (priority order):
1. `.baoyu-skills/baoyu-xhs-images/EXTEND.md` (project)
2. `~/.baoyu-skills/baoyu-xhs-images/EXTEND.md` (user)
**If preferences found**:
1. Parse YAML frontmatter
2. Display current preferences summary:
```
Loaded preferences from [path]:
- Watermark: [enabled/disabled] "[content]" at [position]
- Style: [name] - [description]
- Layout: [layout]
- Language: [lang]
```
3. Continue to Step 1
**If NO preferences found**:
1. Ask user with AskUserQuestion (see `references/config/first-time-setup.md`)
2. Create EXTEND.md with user choices
3. Continue to Step 1
Schema reference: `references/config/preferences-schema.md`
### Step 1: Analyze Content → `analysis.md` ### Step 1: Analyze Content → `analysis.md`
Read source content, save it if needed, and perform deep analysis. Read source content, save it if needed, and perform deep analysis.
@@ -136,7 +213,7 @@ Read source content, save it if needed, and perform deep analysis.
- If user provides a file path: use as-is - If user provides a file path: use as-is
- If user pastes content: save to `source.md` in target directory - If user pastes content: save to `source.md` in target directory
2. Read source content 2. Read source content
3. **Deep analysis** following `references/analysis-framework.md`: 3. **Deep analysis** following `references/workflows/analysis-framework.md`:
- Content type classification (种草/干货/测评/教程/避坑...) - Content type classification (种草/干货/测评/教程/避坑...)
- Hook analysis (爆款标题潜力) - Hook analysis (爆款标题潜力)
- Target audience identification - Target audience identification
@@ -145,70 +222,101 @@ Read source content, save it if needed, and perform deep analysis.
- Swipe flow design - Swipe flow design
4. Detect source language 4. Detect source language
5. Determine recommended image count (2-10) 5. Determine recommended image count (2-10)
6. Select 3 style+layout combinations 6. **Generate clarifying questions** (see Step 2)
7. **Save to `analysis.md`** 7. **Save to `analysis.md`**
### Step 2: Generate 3 Outline Variants ### Step 2: Confirmation 1 - Content Understanding ⚠️
Based on analysis, create three distinct style variants. **Purpose**: Validate understanding + collect missing info. **Do NOT skip.**
**For each variant**: **Display summary**:
1. **Generate outline** (`outline-style-[slug].md`): - Content type + topic identified
- YAML front matter with style, layout, image_count - Key points extracted
- Cover design with hook - Tone detected
- Each image: layout, core message, text content, visual concept - Source images count
- **Written in user's preferred language**
- Reference: `references/outline-template.md`
| Variant | Selection Logic | Example Filename | **Use AskUserQuestion** for:
|---------|-----------------|------------------| 1. Core selling point (multiSelect: true)
| A | Primary recommendation | `outline-style-tech.md` | 2. Target audience
| B | Alternative style | `outline-style-notion.md` | 3. Style preference: Authentic sharing / Professional review / Aesthetic mood / Auto
| C | Different audience/mood | `outline-style-minimal.md` | 4. Additional context (optional)
**All variants are preserved after selection for reference.** **After response**: Update `analysis.md` → Step 3
### Step 3: User Confirms All Options ### Step 3: Generate 3 Outline + Style Variants
**IMPORTANT**: Present ALL options in a single confirmation step using AskUserQuestion. Do NOT interrupt workflow with multiple separate confirmations. Based on analysis + user context, create three distinct strategy variants. Each variant includes both **outline structure** and **visual style recommendation**.
**Determine which questions to ask**: **For each strategy**:
| Question | When to Ask | | Strategy | Filename | Outline | Recommended Style |
|----------|-------------| |----------|----------|---------|-------------------|
| Style variant | Always (required) | | A | `outline-strategy-a.md` | Story-driven: emotional, before/after | warm, cute, fresh |
| Default layout | Only if user might want to override | | B | `outline-strategy-b.md` | Information-dense: structured, factual | notion, minimal, chalkboard |
| Language | Only if `source_language ≠ user_language` | | C | `outline-strategy-c.md` | Visual-first: atmospheric, minimal text | bold, pop, retro |
**Language handling**: **Outline format** (YAML front matter + content):
- If source language = user language: Just inform user (e.g., "Images will be in Chinese") ```yaml
- If different: Ask which language to use ---
strategy: a # a, b, or c
name: Story-Driven
style: warm # recommended style for this strategy
style_reason: "Warm tones enhance emotional storytelling and personal connection"
layout: balanced # primary layout
image_count: 5
---
**AskUserQuestion format**: ## P1 Cover
**Type**: cover
**Hook**: "入冬后脸不干了🥹终于找到对的面霜"
**Visual**: Product hero shot with cozy winter atmosphere
**Layout**: sparse
``` ## P2 Problem
Question 1 (Style): Which style variant? **Type**: pain-point
- A: tech + dense (Recommended) - 专业科技感,适合干货 **Message**: Previous struggles with dry skin
- B: notion + list - 清爽知识卡片 **Visual**: Before state, relatable scenario
- C: minimal + balanced - 简约高端风格 **Layout**: balanced
- Custom: 自定义风格描述
Question 2 (Layout) - only if relevant: ...
- Keep variant default (Recommended)
- sparse / balanced / dense / list / comparison / flow
Question 3 (Language) - only if mismatch:
- 中文 (匹配原文)
- English (your preference)
``` ```
**After confirmation**: **Differentiation requirements**:
1. Copy selected `outline-style-[slug].md``outline.md` - Each strategy MUST have different outline structure AND different recommended style
2. Update YAML front matter with confirmed options - Adapt page count: A typically 4-6, B typically 3-5, C typically 3-4
3. If custom style: regenerate outline with that style - Include `style_reason` explaining why this style fits the strategy
4. User may edit `outline.md` directly for fine-tuning - Consider user's style preference from Step 2
### Step 4: Generate Images Reference: `references/workflows/outline-template.md`
### Step 4: Confirmation 2 - Outline & Style Selection ⚠️
**Purpose**: User chooses outline strategy AND confirms visual style. **Do NOT skip.**
**Display each strategy**:
- Strategy name + page count + recommended style
- Page-by-page summary (P1 → P2 → P3...)
**Use AskUserQuestion** with two questions:
**Question 1: Outline Strategy**
- Strategy A (Recommended if "authentic sharing")
- Strategy B (Recommended if "professional review")
- Strategy C (Recommended if "aesthetic mood")
- Combine: specify pages from each
**Question 2: Visual Style**
- Use strategy's recommended style (show which style)
- Or select from: cute / fresh / warm / bold / minimal / retro / pop / notion / chalkboard
- Or type custom style description
**After response**:
- Single strategy → copy to `outline.md` with confirmed style
- Combination → merge specified pages with confirmed style
- Custom request → regenerate based on feedback
- Update `outline.md` frontmatter with final style choice
### Step 5: Generate Images
With confirmed outline + style + layout: With confirmed outline + style + layout:
@@ -217,6 +325,15 @@ With confirmed outline + style + layout:
2. Generate image using confirmed style and layout 2. Generate image using confirmed style and layout
3. Report progress after each generation 3. Report progress after each generation
**Watermark Application** (if enabled in preferences):
Add to each image generation prompt:
```
Include a subtle watermark "[content]" positioned at [position]
with approximately [opacity*100]% visibility. The watermark should
be legible but not distracting from the main content.
```
Reference: `references/config/watermark-guide.md`
**Image Generation Skill Selection**: **Image Generation Skill Selection**:
- Check available image generation skills - Check available image generation skills
- If multiple skills available, ask user preference - If multiple skills available, ask user preference
@@ -227,22 +344,23 @@ If image generation skill supports `--sessionId`:
2. Use same session ID for all images 2. Use same session ID for all images
3. Ensures visual consistency across generated images 3. Ensures visual consistency across generated images
### Step 5: Completion Report ### Step 6: Completion Report
``` ```
Xiaohongshu Infographic Series Complete! Xiaohongshu Infographic Series Complete!
Topic: [topic] Topic: [topic]
Strategy: [A/B/C/Combined]
Style: [style name] Style: [style name]
Layout: [layout name or "varies"] Layout: [layout name or "varies"]
Location: [directory path] Location: [directory path]
Images: N total Images: N total
✓ analysis.md ✓ analysis.md
✓ outline-style-tech.md ✓ outline-strategy-a.md
✓ outline-style-notion.md ✓ outline-strategy-b.md
✓ outline-style-minimal.md ✓ outline-strategy-c.md
✓ outline.md (selected: tech + dense) ✓ outline.md (selected: [strategy])
Files: Files:
- 01-cover-[slug].png ✓ Cover (sparse) - 01-cover-[slug].png ✓ Cover (sparse)
@@ -253,25 +371,11 @@ Files:
## Image Modification ## Image Modification
### Edit Single Image | Action | Steps |
|--------|-------|
1. Identify image to edit (e.g., `03-content-chatgpt.png`) | **Edit** | Update prompt → Regenerate with same session ID |
2. Update prompt in `prompts/03-content-chatgpt.md` if needed | **Add** | Specify position → Create prompt → Generate → Renumber subsequent files (NN+1) → Update outline |
3. Regenerate image using same session ID | **Delete** | Remove files → Renumber subsequent (NN-1) → Update outline |
### Add New Image
1. Specify insertion position (e.g., after image 3)
2. Create new prompt with appropriate slug
3. Generate new image
4. **Renumber files**: All subsequent images increment NN by 1
5. Update `outline.md` with new image entry
### Delete Image
1. Remove image file and prompt file
2. **Renumber files**: All subsequent images decrement NN by 1
3. Update `outline.md` to remove image entry
## Content Breakdown Principles ## Content Breakdown Principles
@@ -295,27 +399,39 @@ Files:
## References ## References
Detailed templates and guidelines in `references/` directory: Detailed templates in `references/` directory:
- `analysis-framework.md` - XHS-specific content analysis
- `outline-template.md` - Outline format and examples **Elements** (Visual building blocks):
- `styles/<style>.md` - Detailed style definitions - `elements/canvas.md` - Aspect ratios, safe zones, grid layouts
- `layouts/<layout>.md` - Detailed layout definitions - `elements/image-effects.md` - Cutout, stroke, filters
- `base-prompt.md` - Base prompt template - `elements/typography.md` - Decorated text (花字), tags, text direction
- `elements/decorations.md` - Emphasis marks, backgrounds, doodles, frames
**Presets** (Style presets):
- `presets/<name>.md` - Element combination definitions (cute, notion, warm...)
**Workflows** (Process guides):
- `workflows/analysis-framework.md` - Content analysis framework
- `workflows/outline-template.md` - Outline template with layout guide
- `workflows/prompt-assembly.md` - Prompt assembly guide
**Config** (Settings):
- `config/preferences-schema.md` - EXTEND.md schema
- `config/first-time-setup.md` - First-time setup flow
- `config/watermark-guide.md` - Watermark configuration
## Notes ## Notes
- Image generation typically takes 10-30 seconds per image - Auto-retry once on failure | Cartoon alternatives for sensitive figures
- Auto-retry once on generation failure - Use confirmed language preference | Maintain style consistency
- Use cartoon alternatives for sensitive public figures - **Two confirmation points required** (Steps 2 & 4) - do not skip
- All prompts and text use confirmed language preference
- Maintain style consistency across all images in series
## Extension Support ## Extension Support
Custom styles and configurations via EXTEND.md. Custom configurations via EXTEND.md. Loaded in Step 0, overrides defaults.
**Check paths** (priority order): **Check paths** (priority): `.baoyu-skills/baoyu-xhs-images/EXTEND.md` (project) → `~/.baoyu-skills/baoyu-xhs-images/EXTEND.md` (user)
1. `.baoyu-skills/baoyu-xhs-images/EXTEND.md` (project)
2. `~/.baoyu-skills/baoyu-xhs-images/EXTEND.md` (user)
If found, load before Step 1. Extension content overrides defaults. **Supports**: Watermark | Preferred style/layout | Custom style definitions
**References**: `config/preferences-schema.md` | `config/first-time-setup.md`
@@ -1,33 +0,0 @@
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:
@@ -0,0 +1,114 @@
---
name: first-time-setup
description: First-time setup flow for baoyu-xhs-images preferences
---
# First-Time Setup
## Overview
When no EXTEND.md is found, guide user through preference setup.
## Setup Flow
```
No EXTEND.md found
┌─────────────────────┐
│ AskUserQuestion │
│ (all questions) │
└─────────────────────┘
┌─────────────────────┐
│ Create EXTEND.md │
└─────────────────────┘
Continue to Step 1
```
## Questions
**Language**: Use user's input language or preferred language for all questions. Do not always use English.
Use single AskUserQuestion with multiple questions (AskUserQuestion auto-adds "Other" option):
### Question 1: Watermark
```
header: "Watermark"
question: "Watermark text for generated images? Type your watermark content (e.g., name, @handle)"
options:
- label: "No watermark (Recommended)"
description: "No watermark, can enable later in EXTEND.md"
```
Position defaults to bottom-right.
### Question 2: Preferred Style
```
header: "Style"
question: "Default visual style preference? Or type another style name or your custom style"
options:
- label: "None (Recommended)"
description: "Auto-select based on content analysis"
- label: "cute"
description: "Sweet, adorable - classic XHS aesthetic"
- label: "notion"
description: "Minimalist hand-drawn, intellectual"
```
### Question 3: Save Location
```
header: "Save"
question: "Where to save preferences?"
options:
- label: "Project"
description: ".baoyu-skills/ (this project only)"
- label: "User"
description: "~/.baoyu-skills/ (all projects)"
```
## Save Locations
| Choice | Path | Scope |
|--------|------|-------|
| Project | `.baoyu-skills/baoyu-xhs-images/EXTEND.md` | Current project |
| User | `~/.baoyu-skills/baoyu-xhs-images/EXTEND.md` | All projects |
## After Setup
1. Create directory if needed
2. Write EXTEND.md with frontmatter
3. Confirm: "Preferences saved to [path]"
4. Continue to Step 1
## EXTEND.md Template
```yaml
---
version: 1
watermark:
enabled: [true/false]
content: "[user input or empty]"
position: bottom-right
opacity: 0.7
preferred_style:
name: [selected style or null]
description: ""
preferred_layout: null
language: null
custom_styles: []
---
```
## Modifying Preferences Later
Users can edit EXTEND.md directly or run setup again:
- Delete EXTEND.md to trigger setup
- Edit YAML frontmatter for quick changes
- Full schema: `config/preferences-schema.md`
@@ -0,0 +1,121 @@
---
name: preferences-schema
description: EXTEND.md YAML schema for baoyu-xhs-images user preferences
---
# Preferences Schema
## Full Schema
```yaml
---
version: 1
watermark:
enabled: false
content: ""
position: bottom-right # bottom-right|bottom-left|bottom-center|top-right
opacity: 0.7 # 0.1-1.0
preferred_style:
name: null # Built-in or custom style name
description: "" # Override/notes
preferred_layout: null # sparse|balanced|dense|list|comparison|flow
language: null # zh|en|ja|ko|auto
custom_styles:
- name: my-style
description: "Style description"
color_palette:
primary: ["#FED7E2", "#FEEBC8"]
background: "#FFFAF0"
accents: ["#FF69B4", "#FF6B6B"]
visual_elements: "Hearts, stars, sparkles"
typography: "Rounded, bubbly hand lettering"
best_for: "Lifestyle, beauty"
---
```
## Field Reference
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `version` | int | 1 | Schema version |
| `watermark.enabled` | bool | false | Enable watermark |
| `watermark.content` | string | "" | Watermark text (@username or custom) |
| `watermark.position` | enum | bottom-right | Position on image |
| `watermark.opacity` | float | 0.7 | Transparency (0.1-1.0) |
| `preferred_style.name` | string | null | Style name or null |
| `preferred_style.description` | string | "" | Custom notes/override |
| `preferred_layout` | string | null | Layout preference or null |
| `language` | string | null | Output language (null = auto-detect) |
| `custom_styles` | array | [] | User-defined styles |
## Position Options
| Value | Description |
|-------|-------------|
| `bottom-right` | Lower right corner (default, most common) |
| `bottom-left` | Lower left corner |
| `bottom-center` | Bottom center |
| `top-right` | Upper right corner |
## Custom Style Fields
| Field | Required | Description |
|-------|----------|-------------|
| `name` | Yes | Unique style identifier (kebab-case) |
| `description` | Yes | What the style conveys |
| `color_palette.primary` | No | Main colors (array) |
| `color_palette.background` | No | Background color |
| `color_palette.accents` | No | Accent colors (array) |
| `visual_elements` | No | Decorative elements |
| `typography` | No | Font/lettering style |
| `best_for` | No | Recommended content types |
## Example: Minimal Preferences
```yaml
---
version: 1
watermark:
enabled: true
content: "@myusername"
preferred_style:
name: notion
---
```
## Example: Full Preferences
```yaml
---
version: 1
watermark:
enabled: true
content: "@myxhsaccount"
position: bottom-right
opacity: 0.5
preferred_style:
name: notion
description: "Clean knowledge cards for tech content"
preferred_layout: dense
language: zh
custom_styles:
- name: corporate
description: "Professional B2B style"
color_palette:
primary: ["#1E3A5F", "#4A90D9"]
background: "#F5F7FA"
accents: ["#00B4D8", "#48CAE4"]
visual_elements: "Clean lines, subtle gradients, geometric shapes"
typography: "Modern sans-serif, professional"
best_for: "Business, SaaS, enterprise"
---
```
@@ -0,0 +1,73 @@
---
name: watermark-guide
description: Watermark configuration guide for baoyu-xhs-images
---
# Watermark Guide
## Position Diagram
```
┌─────────────────────────────┐
│ [top-right]│
│ │
│ │
│ IMAGE CONTENT │
│ │
│ │
│[bottom-left][bottom-center][bottom-right]│
└─────────────────────────────┘
```
## Position Recommendations
| Position | Best For | Avoid When |
|----------|----------|------------|
| `bottom-right` | Default choice, most common | Key info in bottom-right |
| `bottom-left` | Right-heavy layouts | Key info in bottom-left |
| `bottom-center` | Centered designs | Text-heavy bottom area |
| `top-right` | Bottom-heavy content | Title/header in top-right |
## Opacity Examples
| Opacity | Visual Effect | Use Case |
|---------|---------------|----------|
| 0.3 | Very subtle, barely visible | Clean aesthetic priority |
| 0.5 | Balanced, noticeable but not distracting | Default recommendation |
| 0.7 | Clearly visible | Brand recognition priority |
| 0.9 | Strong presence | Anti-copy protection |
## Content Format
| Format | Example | Style |
|--------|---------|-------|
| Handle | `@username` | Most common for XHS |
| Text | `MyBrand` | Simple branding |
| Chinese | `小红书:用户名` | Platform specific |
| URL | `myblog.com` | Cross-platform |
## Best Practices
1. **Consistency**: Use same watermark across all images in series
2. **Legibility**: Ensure watermark readable on both light/dark areas
3. **Size**: Keep subtle - should not distract from content
4. **Contrast**: Opacity may need adjustment per image background
## Prompt Integration
When watermark is enabled, add to image generation prompt:
```
Include a subtle watermark "[content]" positioned at [position]
with approximately [opacity*100]% visibility. The watermark should
be legible but not distracting from the main content.
```
## Common Issues
| Issue | Solution |
|-------|----------|
| Watermark invisible | Increase opacity or adjust position |
| Watermark too prominent | Decrease opacity (0.3-0.5) |
| Watermark overlaps content | Change position |
| Inconsistent across images | Use session ID for consistency |
@@ -0,0 +1,108 @@
# Canvas & Layout
Core canvas specifications and layout grids for Xiaohongshu infographics.
## Aspect Ratios
| Name | Ratio | Pixels | Note |
|------|-------|--------|------|
| portrait-3-4 | 3:4 | 1242×1660 | Highest traffic on XHS (recommended) |
| square | 1:1 | 1242×1242 | Second recommended |
| portrait-2-3 | 2:3 | 1242×1863 | Taller format |
**Default**: portrait-3-4 for maximum engagement.
## Safe Zones
Avoid placing critical content in these areas:
| Zone | Position | Reason |
|------|----------|--------|
| bottom-overlay | Bottom 10% | Title bar overlay on mobile |
| top-right | Top-right corner | Like/share button overlay |
| bottom-right | Bottom-right corner | Watermark position |
```
┌─────────────────────────────┐
│ [like/share]│ ← top-right: avoid
│ │
│ │
│ ✓ SAFE CONTENT AREA │
│ │
│ │
│ [title bar overlay area] │ ← bottom 10%: avoid key info
└─────────────────────────────┘
```
## Grid Layouts
### Density-Based Layouts
| Layout | Info Density | Whitespace | Points/Image | Best For |
|--------|--------------|------------|--------------|----------|
| sparse | Low | 60-70% | 1-2 | Covers, quotes, impactful statements |
| balanced | Medium | 40-50% | 3-4 | Standard content, tutorials |
| dense | High | 20-30% | 5-8 | Knowledge cards, cheat sheets |
### Structure-Based Layouts
| Layout | Structure | Items | Best For |
|--------|-----------|-------|----------|
| list | Vertical enumeration | 4-7 | Rankings, checklists, step guides |
| comparison | Left vs Right | 2 sections | Before/after, pros/cons |
| flow | Connected nodes | 3-6 steps | Processes, timelines, workflows |
## Layout by Position
| Position | Recommended Layout | Why |
|----------|-------------------|-----|
| Cover | sparse | Maximum visual impact, clear title |
| Setup | balanced | Context without overwhelming |
| Core | balanced/dense/list | Based on content density |
| Payoff | balanced/list | Clear takeaways |
| Ending | sparse | Clean CTA, memorable close |
## Grid Cells
For multi-element compositions:
| Name | Cells | Use Case |
|------|-------|----------|
| single | 1 | Hero image, maximum impact |
| dual | 2 | Before/after, comparison |
| triptych | 3 | Steps, process flow |
| quad | 4 | Product showcase |
| six-grid | 6 | Checklist, collection |
| nine-grid | 9 | Multi-image gallery |
## Visual Balance
### Sparse Layout
- Single focal point centered
- Breathing room on all sides
- Symmetrical composition
### Balanced Layout
- Top-weighted title
- Evenly distributed content below
- Clear visual hierarchy
### Dense Layout
- Organized grid structure
- Clear section boundaries
- Compact but readable spacing
### List Layout
- Left-aligned items
- Clear number/bullet hierarchy
- Consistent item format
### Comparison Layout
- Symmetrical left/right
- Clear visual contrast
- Divider between sections
### Flow Layout
- Directional flow (top→bottom or left→right)
- Connected nodes with arrows
- Clear progression indicators
@@ -0,0 +1,145 @@
# Decorative Assets
Visual embellishments and decorative elements for Xiaohongshu infographics.
## Emphasis Marks (强调标记)
Elements to draw attention to specific content.
| Name | Description | Use Case |
|------|-------------|----------|
| red-arrow | Red arrow pointing to target | Product features, key points |
| circle-mark | Circle highlight annotation | Highlighting details |
| underline | Straight or wavy underline | Text emphasis |
| star-burst | Starburst explosion effect | Special offers, wow factor |
| checkmark | Checkmark/tick symbol | Completed items, pros |
| cross-mark | X mark symbol | Cons, things to avoid |
| exclamation | Exclamation point decoration | Important warnings |
| question | Question mark decoration | FAQ, curiosity |
| numbering | Circled numbers | Steps, rankings |
| bracket | Bracket highlighting | Grouping, emphasis |
## Backgrounds (背景)
Base layer treatments.
| Name | Description | Use Case |
|------|-------------|----------|
| solid-saturated | High-saturation solid color | Bold, energetic |
| solid-pastel | Soft pastel solid color | Cute, gentle |
| gradient-linear | Linear color gradient | Modern, dynamic |
| gradient-radial | Radial color gradient | Spotlight effect |
| frosted-glass | Frosted glass blur effect | Layered compositions |
| paper-texture | Paper or craft texture | Handmade aesthetic |
| fabric-texture | Fabric/cloth texture | Cozy, tactile |
| chalkboard | Blackboard texture | Educational content |
| grid | Subtle grid pattern | Structured, organized |
| dots | Polka dot pattern | Playful, retro |
## Doodles & Emoji (涂鸦)
Hand-drawn decorative elements.
| Name | Description | Use Case |
|------|-------------|----------|
| hand-drawn-lines | Sketchy hand-drawn lines | Connections, borders |
| stars-sparkles | Stars and sparkle effects | Magic, excellence |
| flowers | Floral decorations | Beauty, feminine |
| hearts | Heart symbols | Love, favorites |
| clouds | Cloud shapes | Dreamy, thoughts |
| arrows-curvy | Curved directional arrows | Flow, direction |
| squiggles | Wavy squiggle lines | Energy, movement |
| confetti | Scattered confetti | Celebration |
| leaves | Leaf decorations | Nature, fresh |
| bubbles | Circular bubble shapes | Playful, light |
## Emoji Integration
| Category | Examples | Use Case |
|----------|----------|----------|
| Reactions | 🥹 😍 🤯 | Emotional emphasis |
| Objects | ✨ 💡 🎯 | Visual markers |
| Actions | 👇 👆 ➡️ | Directional cues |
| Nature | 🌸 🌿 ☀️ | Thematic decoration |
## Frames (边框)
Container and border treatments.
| Name | Description | Use Case |
|------|-------------|----------|
| polaroid | Instant photo frame | Photo showcase |
| film-strip | Film negative border | Cinematic, retro |
| phone-screenshot | Mobile device mockup | App/screen content |
| torn-paper | Torn paper edge effect | Scrapbook aesthetic |
| rounded-rect | Rounded rectangle border | Clean containers |
| decorative | Ornate decorative border | Premium, elegant |
| tape-corners | Washi tape corners | Crafty, casual |
| stamp-border | Stamp perforated edge | Vintage, postal |
## Dividers (分隔线)
Section separators.
| Name | Description | Use Case |
|------|-------------|----------|
| line-simple | Simple horizontal line | Clean separation |
| line-dashed | Dashed line | Subtle division |
| line-wavy | Wavy line | Playful separation |
| dots-row | Row of dots | Decorative division |
| ornamental | Decorative flourish | Elegant separation |
## Stickers (贴纸)
Pre-composed decorative elements.
| Name | Description | Use Case |
|------|-------------|----------|
| badge-new | "NEW" badge | New products |
| badge-hot | "HOT" badge | Trending items |
| badge-sale | Sale/discount badge | Promotions |
| seal-quality | Quality seal | Recommendations |
| ribbon-award | Award ribbon | Best picks |
| tag-price | Price tag shape | Pricing info |
## Style-Specific Decorations
### Cute Style
- Hearts, stars, sparkles
- Ribbon decorations, sticker-style
- Cute character elements
### Notion Style
- Simple line doodles
- Geometric shapes, stick figures
- Maximum whitespace, minimal decoration
### Warm Style
- Sun rays, coffee cups, cozy items
- Warm lighting effects
- Friendly, inviting decorations
### Fresh Style
- Plant leaves, clouds, water drops
- Simple geometric shapes
- Open, breathing composition
### Bold Style
- Exclamation marks, arrows
- Warning icons, strong shapes
- High contrast elements
### Pop Style
- Bold shapes, speech bubbles
- Comic-style effects, starburst
- Dynamic, energetic decorations
### Retro Style
- Halftone dots, vintage badges
- Classic icons, tape effects
- Aged texture overlays
### Chalkboard Style
- Chalk dust effects
- Hand-drawn doodles
- Mathematical formulas, simple icons
@@ -0,0 +1,92 @@
# Image Processing Layer
Visual effects applied to image elements in Xiaohongshu infographics.
## AI Cutout (抠图)
Subject extraction styles for product/figure isolation.
| Name | Description | Use Case |
|------|-------------|----------|
| clean | Sharp edges, precise boundaries | Product photography, tech items |
| soft | Soft transition, feathered edges | Portrait cutout, organic subjects |
| stylized | Hand-drawn edge treatment | Artistic compositions |
## Stroke Effects (描边)
Border treatments for cutout elements.
| Name | Description | Use Case |
|------|-------------|----------|
| white-solid | White solid line border | Classic sticker feel, high contrast |
| colored-solid | Colored solid line border | Playful vibe, brand colors |
| dashed | Dashed/dotted border | Handmade aesthetic, casual |
| double | Double-layer stroke | Emphasis effect, premium feel |
| glow | Soft outer glow | Dreamy, soft aesthetic |
| shadow | Drop shadow effect | Depth, floating element |
**Stroke Width Guidelines**:
- Thin: 2-4px - Subtle, elegant
- Medium: 5-8px - Standard visibility
- Thick: 10-15px - Bold emphasis
## Filters (滤镜)
Color grading and mood presets popular on XHS.
| Name | Chinese | Description | Mood |
|------|---------|-------------|------|
| clear-glow | 清透感 | Transparent, radiant, luminous | Fresh, youthful |
| film-grain | 胶片感 | Vintage film aesthetic, grain texture | Nostalgic, artistic |
| cream-skin | 奶油肌 | Smooth, creamy complexion tones | Soft, flattering |
| japanese-magazine | 日杂感 | Lifestyle magazine aesthetic | Curated, aspirational |
| high-saturation | 高饱和 | Vibrant, punchy colors | Energetic, eye-catching |
| muted-tones | 莫兰迪 | Morandi-style desaturated palette | Sophisticated, calm |
| warm-tone | 暖色调 | Golden hour warmth | Cozy, inviting |
| cool-tone | 冷色调 | Blue-shifted coolness | Modern, clean |
## Texture Overlays
Additional texture effects.
| Name | Description | Use Case |
|------|-------------|----------|
| paper | Paper or fabric texture | Handmade feel |
| noise | Fine grain noise | Analog aesthetic |
| halftone | Dot pattern | Retro print style |
| scratch | Light scratch marks | Vintage wear |
## Blending Modes
For layered compositions.
| Mode | Effect | Use Case |
|------|--------|----------|
| multiply | Darken, merge | Shadow effects |
| screen | Lighten, glow | Light effects |
| overlay | Contrast boost | Vibrant compositions |
| soft-light | Subtle blending | Natural layering |
## Effect Combinations
Common effect stacks for different styles:
### Cute Style
- Filter: clear-glow or cream-skin
- Stroke: white-solid (medium)
- Texture: none
### Notion Style
- Filter: none or muted-tones
- Stroke: white-solid (thin) or none
- Texture: paper (subtle)
### Retro Style
- Filter: film-grain
- Stroke: double or dashed
- Texture: halftone, scratch
### Bold Style
- Filter: high-saturation
- Stroke: colored-solid (thick)
- Texture: none
@@ -0,0 +1,96 @@
# Typography System
Text styling elements for Xiaohongshu infographics.
## Decorated Text (花字)
Stylized text treatments for emphasis and visual appeal.
| Name | Description | Use Case |
|------|-------------|----------|
| gradient | Gradient color fill | Title emphasis, modern feel |
| stroke-text | Outlined text with stroke | Cover headlines, high visibility |
| shadow-3d | 3D shadow/extrusion effect | Key terms, depth |
| highlight | Highlighter marker effect | Critical information, key points |
| neon | Neon glow effect | Tech content, night aesthetic |
| handwritten | Authentic handwritten style | Personal touch, casual |
| bubble | Rounded, inflated letterforms | Cute, playful content |
| brush | Brush stroke texture | Artistic, dynamic |
## Tags & Labels (标签)
Structured text containers.
| Name | Description | Use Case |
|------|-------------|----------|
| black-white | Black background, white text | Brand names, prices, categories |
| white-black | White background, black text | Clean labels, minimal style |
| bubble | Speech bubble style | Dialogue, annotations, callouts |
| pointer | Arrow pointer with label | Product callouts, pointing to features |
| ribbon | Ribbon/banner shape | Special offers, highlights |
| stamp | Stamp/seal style | Authenticity, recommendations |
| pill | Rounded pill shape | Tags, categories, keywords |
## Text Hierarchy
Recommended text sizing for visual hierarchy.
| Level | Role | Relative Size | Style |
|-------|------|---------------|-------|
| H1 | Main title | 100% | Bold, decorated |
| H2 | Section header | 70-80% | Semi-bold |
| H3 | Subsection | 50-60% | Medium weight |
| Body | Content text | 40-50% | Regular |
| Caption | Small notes | 30-35% | Light |
## Text Direction
| Direction | Description | Use Case |
|-----------|-------------|----------|
| horizontal | Standard left-to-right | Default for most content |
| vertical | Top-to-bottom columns | Magazine style, traditional Chinese |
| curved | Text following a curve | Decorative, around shapes |
| diagonal | Angled text | Dynamic compositions |
## Text Effects
| Effect | Description | Use Case |
|--------|-------------|----------|
| shadow | Drop shadow behind text | Readability on busy backgrounds |
| outline | Outline around letterforms | High contrast visibility |
| glow | Soft glow around text | Dreamy, emphasis |
| underline-wavy | Wavy underline decoration | Playful emphasis |
| strikethrough | Crossed out text | Before/after, corrections |
## Language Considerations
### Chinese Text (中文)
- Punctuation: 「」()、。!?
- Spacing: No spaces between characters
- Line height: 1.5-1.8x for readability
### Mixed Text
- English in Chinese context: Maintain consistent baseline
- Numbers: Use consistent number style (lining vs old-style)
## Style-Specific Typography
### Cute Style
- Rounded, bubbly hand lettering
- Soft shadows, playful decorations
- Pink/pastel color accents
### Notion Style
- Clean hand-drawn lettering
- Simple sans-serif labels
- Minimal decoration
### Bold Style
- Impactful hand lettering with shadows
- High contrast colors
- Strong outlines
### Chalkboard Style
- Chalk texture on all text
- Visible imperfections
- Multi-color chalk variety
@@ -1,30 +0,0 @@
# balanced
Standard content layout
## Information Density
- 3-4 key points per image
- Whitespace: 40-50% of canvas
## Structure
- Title at top
- 3-4 bullet points or sections below
## Text Elements
- Title + 3-4 bullet points or key messages
## Visual Balance
- Top-weighted title
- Evenly distributed content below
## Best For
Regular content pages, tutorials, explanations
## Best Style Pairings
All styles work well
@@ -1,31 +0,0 @@
# comparison
Side-by-side contrast layout
## Information Density
- 2 main sections with 2-4 points each
- Whitespace: 30-40% of canvas
## Structure
- Left vs Right layout
- Before/After, Pros/Cons format
- Clear divider between sections
## Text Elements
- Title + left label + right label + mirrored points
## Visual Balance
- Symmetrical left/right
- Clear visual contrast
## Best For
Comparisons, transformations, decision helpers, 对比图
## Best Style Pairings
bold (dramatic contrast), tech (data comparison), warm (before/after stories)
@@ -1,31 +0,0 @@
# dense
High information density, knowledge card style
## Information Density
- 5-8 key points per image
- Whitespace: 20-30% of canvas
## Structure
- Multiple sections, structured grid
- More text, compact but organized
## Text Elements
- Title + multiple sections with headers + numerous points
## Visual Balance
- Organized chaos
- Clear section boundaries
- Compact spacing
## Best For
Summary cards, cheat sheets, comprehensive guides, 干货总结
## Best Style Pairings
tech, notion, minimal (clean styles prevent visual overload)
@@ -1,30 +0,0 @@
# flow
Process and timeline layout
## Information Density
- 3-6 steps/stages
- Whitespace: 30-40% of canvas
## Structure
- Connected nodes with arrows
- Sequential flow, directional
## Text Elements
- Title + step labels + optional descriptions per step
## Visual Balance
- Directional flow (top→bottom or left→right)
- Clear progression
## Best For
Processes, timelines, cause-effect chains, workflows
## Best Style Pairings
tech (process diagrams), notion (simple flows), fresh (organic flows)
@@ -1,31 +0,0 @@
# list
Enumeration and ranking format
## Information Density
- 4-7 items
- Whitespace: 30-40% of canvas
## Structure
- Numbered or bulleted vertical list
- Consistent item format
## Text Elements
- Title + numbered/bulleted items
- Consistent format per item
## Visual Balance
- Left-aligned list
- Clear number/bullet hierarchy
## Best For
Top N lists, checklists, step-by-step guides, rankings
## Best Style Pairings
All styles, especially cute (checklist), bold (rankings)
@@ -1,31 +0,0 @@
# sparse
Minimal information, maximum impact
## Information Density
- 1-2 key points per image
- Whitespace: 60-70% of canvas
## Structure
- Single focal point centered
- One core message
- Single centered focal point
## Text Elements
- Title only, or title + one subtitle/tagline
## Visual Balance
- Centered, symmetrical
- Breathing room on all sides
## Best For
Covers, quotes, impactful statements, emotional content
## Best Style Pairings
Any style, especially effective with bold, minimal, notion
@@ -0,0 +1,72 @@
---
name: bold
category: impact
---
# Bold Style
High impact, attention-grabbing aesthetic.
## Element Combination
```yaml
canvas:
ratio: portrait-3-4
grid: single | dual
image_effects:
cutout: clean
stroke: colored-solid | double
filter: high-saturation
typography:
decorated: shadow-3d | stroke-text
tags: black-white | ribbon
direction: horizontal | diagonal
decorations:
emphasis: exclamation | star-burst | red-arrow
background: solid-saturated | gradient-linear
doodles: arrows-curvy | squiggles
frames: none
```
## Color Palette
| Role | Colors | Hex |
|------|--------|-----|
| Primary | Vibrant red, orange, yellow | #E53E3E, #DD6B20, #F6E05E |
| Background | Deep black, dark charcoal | #000000, #1A1A1A |
| Accents | White, neon yellow | #FFFFFF, #F7FF00 |
## Visual Elements
- Exclamation marks, arrows, warning icons
- Strong shapes, high contrast elements
- Dramatic compositions
- Bold geometric forms
## Typography
- Bold, impactful hand lettering with shadows
- High contrast text treatments
- Large, commanding headlines
## Best Layout Pairings
| Layout | Compatibility | Use Case |
|--------|---------------|----------|
| sparse | ✓✓ | Impactful statements |
| balanced | ✓ | Warning content |
| dense | ✓ | Critical information cards |
| list | ✓✓ | Must-know lists, rankings |
| comparison | ✓✓ | Dramatic contrasts |
| flow | ✓ | Critical process steps |
## Best For
- Important tips and warnings
- Must-know content
- Critical announcements
- Rankings and comparisons
- Attention-grabbing hooks
@@ -0,0 +1,97 @@
---
name: chalkboard
category: educational
---
# Chalkboard Style
Black chalkboard background with colorful chalk drawing aesthetic.
## Element Combination
```yaml
canvas:
ratio: portrait-3-4
grid: single | dual | triptych
image_effects:
cutout: stylized
stroke: none
filter: none
typography:
decorated: handwritten
tags: none
direction: horizontal | vertical
decorations:
emphasis: underline | circle-mark | arrows-curvy
background: chalkboard
doodles: hand-drawn-lines | stars-sparkles
frames: none
```
## Color Palette
| Role | Colors | Hex |
|------|--------|-----|
| Background | Chalkboard black, green-black | #1A1A1A, #1C2B1C |
| Primary Text | Chalk white | #F5F5F5 |
| Accent 1 | Chalk yellow | #FFE566 |
| Accent 2 | Chalk pink | #FF9999 |
| Accent 3 | Chalk blue | #66B3FF |
| Accent 4 | Chalk green | #90EE90 |
| Accent 5 | Chalk orange | #FFB366 |
## Visual Elements
- Hand-drawn chalk illustrations with sketchy, imperfect lines
- Chalk dust effects around text and key elements
- Doodles: stars, arrows, underlines, circles, checkmarks
- Mathematical formulas and simple diagrams
- Eraser smudges and chalk residue textures
- Stick figures and simple icons
- Connection lines with hand-drawn feel
## Typography
- Hand-drawn chalk lettering style
- Visible chalk texture on all text
- Imperfect baseline adds authenticity
- White or bright colored chalk for emphasis
## Style Rules
### Do
- Maintain authentic chalk texture on all elements
- Use imperfect, hand-drawn quality throughout
- Add subtle chalk dust and smudge effects
- Create visual hierarchy with color variety
- Include playful doodles and annotations
### Don't
- Use perfect geometric shapes
- Create clean digital-looking lines
- Add photorealistic elements
- Use gradients or glossy effects
## Best Layout Pairings
| Layout | Compatibility | Use Case |
|--------|---------------|----------|
| sparse | ✓✓ | Educational covers |
| balanced | ✓✓ | Standard lessons |
| dense | ✓✓ | Detailed tutorials |
| list | ✓✓ | Learning checklists |
| comparison | ✓ | Concept comparisons |
| flow | ✓✓ | Process explanations |
## Best For
- Educational content
- Tutorials and how-to's
- Classroom themes
- Teaching materials
- Workshops
- Informal learning sessions
- Knowledge sharing
@@ -0,0 +1,72 @@
---
name: cute
category: sweet
---
# Cute Style
Sweet, adorable, girly - classic Xiaohongshu aesthetic.
## Element Combination
```yaml
canvas:
ratio: portrait-3-4
grid: single | dual | quad
image_effects:
cutout: soft
stroke: white-solid | colored-solid
filter: clear-glow | cream-skin
typography:
decorated: bubble | highlight
tags: pill | bubble
direction: horizontal
decorations:
emphasis: star-burst | hearts
background: solid-pastel | gradient-linear
doodles: hearts | stars-sparkles | flowers
frames: polaroid | tape-corners
```
## Color Palette
| Role | Colors | Hex |
|------|--------|-----|
| Primary | Pink, peach, mint, lavender | #FED7E2, #FEEBC8, #C6F6D5, #E9D8FD |
| Background | Cream, soft pink | #FFFAF0, #FFF5F7 |
| Accents | Hot pink, coral | #FF69B4, #FF6B6B |
## Visual Elements
- Hearts, stars, sparkles, cute faces
- Ribbon decorations, sticker-style
- Cute stickers, emoji icons
- Soft, rounded shapes
## Typography
- Rounded, bubbly hand lettering
- Soft shadows, playful decorations
- Pink/pastel color accents on text
## Best Layout Pairings
| Layout | Compatibility | Use Case |
|--------|---------------|----------|
| sparse | ✓✓ | Covers, emotional impact |
| balanced | ✓✓ | Standard cute content |
| dense | ✓ | Cute knowledge cards |
| list | ✓✓ | Checklists, cute rankings |
| comparison | ✓ | Before/after transformations |
| flow | ✓ | Cute step guides |
## Best For
- Lifestyle content
- Beauty and skincare
- Fashion and style
- Daily tips and hacks
- Personal shares
@@ -0,0 +1,72 @@
---
name: fresh
category: natural
---
# Fresh Style
Clean, refreshing, natural aesthetic.
## Element Combination
```yaml
canvas:
ratio: portrait-3-4
grid: single | triptych
image_effects:
cutout: soft
stroke: white-solid | none
filter: clear-glow | cool-tone
typography:
decorated: none | highlight
tags: pill | white-black
direction: horizontal
decorations:
emphasis: checkmark | circle-mark
background: solid-white | solid-pastel
doodles: leaves | clouds | bubbles
frames: rounded-rect | none
```
## Color Palette
| Role | Colors | Hex |
|------|--------|-----|
| Primary | Mint green, sky blue, light yellow | #9AE6B4, #90CDF4, #FAF089 |
| Background | Pure white, soft mint | #FFFFFF, #F0FFF4 |
| Accents | Leaf green, water blue | #48BB78, #4299E1 |
## Visual Elements
- Plant leaves, clouds, water drops
- Simple geometric shapes
- Breathing room, open composition
- Natural, organic elements
## Typography
- Clean, light hand lettering with breathing room
- Airy spacing
- Fresh color accents
## Best Layout Pairings
| Layout | Compatibility | Use Case |
|--------|---------------|----------|
| sparse | ✓✓ | Clean covers |
| balanced | ✓✓ | Standard fresh content |
| dense | ✓ | Organized information |
| list | ✓ | Wellness tips |
| comparison | ✓ | Before/after health |
| flow | ✓✓ | Organic processes |
## Best For
- Health and wellness
- Minimalist lifestyle
- Self-care content
- Nature-related topics
- Clean living tips
@@ -0,0 +1,72 @@
---
name: minimal
category: elegant
---
# Minimal Style
Ultra-clean, sophisticated aesthetic.
## Element Combination
```yaml
canvas:
ratio: portrait-3-4
grid: single
image_effects:
cutout: clean
stroke: none | white-solid
filter: none | muted-tones
typography:
decorated: none
tags: white-black | pill
direction: horizontal
decorations:
emphasis: underline | circle-mark
background: solid-white | solid-pastel
doodles: hand-drawn-lines
frames: none | rounded-rect
```
## Color Palette
| Role | Colors | Hex |
|------|--------|-----|
| Primary | Black, white | #000000, #FFFFFF |
| Background | Off-white, pure white | #FAFAFA, #FFFFFF |
| Accents | Single color (content-derived) | Blue, green, or coral |
## Visual Elements
- Single focal point, thin lines
- Maximum whitespace
- Simple, clean decorations
- Restrained visual elements
## Typography
- Clean, simple hand lettering
- Minimal weight variations
- Elegant spacing
## Best Layout Pairings
| Layout | Compatibility | Use Case |
|--------|---------------|----------|
| sparse | ✓✓ | Elegant statements |
| balanced | ✓✓ | Professional content |
| dense | ✓✓ | Clean knowledge cards |
| list | ✓ | Simple lists |
| comparison | ✓ | Clean comparisons |
| flow | ✓ | Elegant processes |
## Best For
- Professional content
- Serious topics
- Elegant presentations
- High-end products
- Business content
@@ -0,0 +1,73 @@
---
name: notion
category: minimal
---
# Notion Style
Minimalist hand-drawn line art, intellectual aesthetic.
## Element Combination
```yaml
canvas:
ratio: portrait-3-4
grid: single | dual
image_effects:
cutout: clean
stroke: none | white-solid
filter: none | muted-tones
typography:
decorated: none | handwritten
tags: black-white | pill
direction: horizontal
decorations:
emphasis: circle-mark | underline
background: solid-white | paper-texture
doodles: hand-drawn-lines | arrows-curvy
frames: none | rounded-rect
```
## Color Palette
| Role | Colors | Hex |
|------|--------|-----|
| Primary | Black, dark gray | #1A1A1A, #4A4A4A |
| Background | Pure white, off-white | #FFFFFF, #FAFAFA |
| Accents | Pastel blue, pastel yellow, pastel pink | #A8D4F0, #F9E79F, #FADBD8 |
## Visual Elements
- Simple line doodles, hand-drawn wobble effect
- Geometric shapes, stick figures
- Maximum whitespace, single-weight ink lines
- Clean, uncluttered compositions
## Typography
- Clean hand-drawn lettering
- Simple sans-serif labels
- Minimal decoration on text
## Best Layout Pairings
| Layout | Compatibility | Use Case |
|--------|---------------|----------|
| sparse | ✓✓ | Concept covers |
| balanced | ✓✓ | Standard explanations |
| dense | ✓✓ | Knowledge cards, cheat sheets |
| list | ✓✓ | Productivity tips, tool lists |
| comparison | ✓✓ | Data comparisons |
| flow | ✓✓ | Process diagrams |
## Best For
- Knowledge sharing
- Concept explanations
- SaaS content
- Productivity tips
- Tech tutorials
- Professional content
@@ -0,0 +1,72 @@
---
name: pop
category: energetic
---
# Pop Style
Vibrant, energetic, eye-catching aesthetic.
## Element Combination
```yaml
canvas:
ratio: portrait-3-4
grid: single | quad
image_effects:
cutout: stylized
stroke: colored-solid | double
filter: high-saturation
typography:
decorated: stroke-text | shadow-3d
tags: bubble | ribbon
direction: horizontal | curved
decorations:
emphasis: star-burst | exclamation
background: solid-saturated | dots
doodles: stars-sparkles | confetti | squiggles
frames: none
```
## Color Palette
| Role | Colors | Hex |
|------|--------|-----|
| Primary | Bright red, yellow, blue, green | #F56565, #ECC94B, #4299E1, #48BB78 |
| Background | White, light gray | #FFFFFF, #F7FAFC |
| Accents | Neon pink, electric purple | #FF69B4, #9F7AEA |
## Visual Elements
- Bold shapes, speech bubbles
- Comic-style effects, starburst
- Dynamic, energetic compositions
- High-energy decorations
## Typography
- Dynamic, energetic hand lettering with outlines
- Bold color combinations
- Playful, expressive forms
## Best Layout Pairings
| Layout | Compatibility | Use Case |
|--------|---------------|----------|
| sparse | ✓✓ | Exciting announcements |
| balanced | ✓✓ | Fun tutorials |
| dense | ✓ | Packed information |
| list | ✓✓ | Fun facts lists |
| comparison | ✓✓ | Dynamic comparisons |
| flow | ✓ | Energetic processes |
## Best For
- Exciting announcements
- Fun facts
- Engaging tutorials
- Entertainment content
- Youth-oriented content
@@ -0,0 +1,72 @@
---
name: retro
category: vintage
---
# Retro Style
Vintage, nostalgic, trendy aesthetic.
## Element Combination
```yaml
canvas:
ratio: portrait-3-4
grid: single | dual
image_effects:
cutout: stylized
stroke: dashed | double
filter: film-grain | muted-tones
typography:
decorated: brush | handwritten
tags: stamp | ribbon
direction: horizontal
decorations:
emphasis: star-burst | numbering
background: paper-texture | dots
doodles: stars-sparkles | squiggles
frames: polaroid | film-strip | stamp-border
```
## Color Palette
| Role | Colors | Hex |
|------|--------|-----|
| Primary | Muted orange, dusty pink, faded teal | #E07A4D, #D4A5A5, #6B9999 |
| Background | Aged paper, sepia tones | #F5E6D3, #E8DCC8 |
| Accents | Faded red, vintage gold | #C55A5A, #B8860B |
## Visual Elements
- Halftone dots, vintage badges
- Classic icons, tape effects
- Aged texture overlays
- Nostalgic decorative elements
## Typography
- Vintage-style hand lettering
- Classic feel with imperfections
- Aged texture on text
## Best Layout Pairings
| Layout | Compatibility | Use Case |
|--------|---------------|----------|
| sparse | ✓✓ | Vintage covers |
| balanced | ✓✓ | Classic content |
| dense | ✓ | Vintage knowledge cards |
| list | ✓✓ | Classic rankings |
| comparison | ✓ | Then vs now |
| flow | ✓ | Historical timelines |
## Best For
- Throwback content
- Classic tips
- Timeless advice
- Vintage aesthetics
- Nostalgic shares
@@ -0,0 +1,72 @@
---
name: warm
category: cozy
---
# Warm Style
Cozy, friendly, approachable aesthetic.
## Element Combination
```yaml
canvas:
ratio: portrait-3-4
grid: single | dual
image_effects:
cutout: soft
stroke: white-solid | glow
filter: warm-tone | cream-skin
typography:
decorated: highlight | handwritten
tags: ribbon | bubble
direction: horizontal
decorations:
emphasis: star-burst | hearts
background: solid-pastel | gradient-radial
doodles: clouds | stars-sparkles
frames: polaroid | tape-corners
```
## Color Palette
| Role | Colors | Hex |
|------|--------|-----|
| Primary | Warm orange, golden yellow, terracotta | #ED8936, #F6AD55, #C05621 |
| Background | Cream, soft peach | #FFFAF0, #FED7AA |
| Accents | Deep brown, soft red | #744210, #E57373 |
## Visual Elements
- Sun rays, coffee cups, cozy items
- Warm lighting effects
- Friendly, inviting decorations
- Soft, comfortable shapes
## Typography
- Friendly, rounded hand lettering
- Warm color accents
- Comfortable, approachable feel
## Best Layout Pairings
| Layout | Compatibility | Use Case |
|--------|---------------|----------|
| sparse | ✓✓ | Emotional covers |
| balanced | ✓✓ | Personal stories |
| dense | ✓ | Detailed experiences |
| list | ✓ | Life lessons |
| comparison | ✓✓ | Before/after stories |
| flow | ✓ | Journey narratives |
## Best For
- Personal stories
- Life lessons
- Emotional content
- Comfort and lifestyle
- Heartfelt shares
@@ -1,23 +0,0 @@
# bold
High impact, attention-grabbing
## Color Palette
- Primary: Vibrant red (#E53E3E), orange (#DD6B20), yellow (#F6E05E)
- Background: Deep black (#000000), dark charcoal
- Accents: White, neon yellow
## Visual Elements
- Exclamation marks, arrows, warning icons
- Strong shapes, high contrast elements
- Dramatic compositions
## Typography
- Bold, impactful hand lettering with shadows
## Best For
Important tips, warnings, must-know content
@@ -1,61 +0,0 @@
# chalkboard
Black chalkboard background with colorful chalk drawing style
## Design Aesthetic
Classic classroom chalkboard aesthetic with hand-drawn chalk illustrations. Nostalgic educational feel with imperfect, sketchy lines that capture the warmth of traditional teaching. Colorful chalk creates visual hierarchy while maintaining the authentic chalkboard experience.
## Background
- Color: Chalkboard Black (#1A1A1A) or Dark Green-Black (#1C2B1C)
- Texture: Realistic chalkboard texture with subtle scratches, dust particles, and faint eraser marks
## Typography
Hand-drawn chalk lettering style with visible chalk texture. Imperfect baseline adds authenticity. White or bright colored chalk for emphasis.
## Color Palette
| Role | Color | Hex | Usage |
|------|-------|-----|-------|
| Background | Chalkboard Black | #1A1A1A | Primary background |
| Alt Background | Green-Black | #1C2B1C | Traditional green board |
| Primary Text | Chalk White | #F5F5F5 | Main text, outlines |
| Accent 1 | Chalk Yellow | #FFE566 | Highlights, emphasis |
| Accent 2 | Chalk Pink | #FF9999 | Secondary highlights |
| Accent 3 | Chalk Blue | #66B3FF | Diagrams, links |
| Accent 4 | Chalk Green | #90EE90 | Success, nature |
| Accent 5 | Chalk Orange | #FFB366 | Warnings, energy |
## Visual Elements
- Hand-drawn chalk illustrations with sketchy, imperfect lines
- Chalk dust effects around text and key elements
- Doodles: stars, arrows, underlines, circles, checkmarks
- Mathematical formulas and simple diagrams
- Eraser smudges and chalk residue textures
- Wooden frame border optional
- Stick figures and simple icons
- Connection lines with hand-drawn feel
## Style Rules
### Do
- Maintain authentic chalk texture on all elements
- Use imperfect, hand-drawn quality throughout
- Add subtle chalk dust and smudge effects
- Create visual hierarchy with color variety
- Include playful doodles and annotations
### Don't
- Use perfect geometric shapes
- Create clean digital-looking lines
- Add photorealistic elements
- Use gradients or glossy effects
## Best For
Educational content, tutorials, classroom themes, teaching materials, workshops, informal learning sessions, knowledge sharing
@@ -1,23 +0,0 @@
# cute
Sweet, adorable, girly - classic Xiaohongshu aesthetic
## Color Palette
- Primary: Pink (#FED7E2), peach (#FEEBC8), mint (#C6F6D5), lavender (#E9D8FD)
- Background: Cream (#FFFAF0), soft pink (#FFF5F7)
- Accents: Hot pink, coral
## Visual Elements
- Hearts, stars, sparkles, cute faces
- Ribbon decorations, sticker-style
- Cute stickers, emoji icons
## Typography
- Rounded, bubbly hand lettering
## Best For
Lifestyle, beauty, fashion, daily tips
@@ -1,23 +0,0 @@
# fresh
Clean, refreshing, natural
## Color Palette
- Primary: Mint green (#9AE6B4), sky blue (#90CDF4), light yellow (#FAF089)
- Background: Pure white (#FFFFFF), soft mint (#F0FFF4)
- Accents: Leaf green, water blue
## Visual Elements
- Plant leaves, clouds, water drops
- Simple geometric shapes
- Breathing room, open composition
## Typography
- Clean, light hand lettering with breathing room
## Best For
Health, wellness, minimalist lifestyle, self-care
@@ -1,23 +0,0 @@
# minimal
Ultra-clean, sophisticated
## Color Palette
- Primary: Black (#000000), white (#FFFFFF)
- Background: Off-white (#FAFAFA), pure white
- Accents: Single color (content-derived - blue, green, or coral)
## Visual Elements
- Single focal point, thin lines
- Maximum whitespace
- Simple, clean decorations
## Typography
- Clean, simple hand lettering
## Best For
Professional content, serious topics, elegant presentations
@@ -1,23 +0,0 @@
# notion
Minimalist hand-drawn line art, intellectual
## Color Palette
- Primary: Black (#1A1A1A), dark gray (#4A4A4A)
- Background: Pure white (#FFFFFF), off-white (#FAFAFA)
- Accents: Pastel blue (#A8D4F0), pastel yellow (#F9E79F), pastel pink (#FADBD8)
## Visual Elements
- Simple line doodles, hand-drawn wobble effect
- Geometric shapes, stick figures
- Maximum whitespace, single-weight ink lines
## Typography
- Clean hand-drawn lettering, simple sans-serif labels
## Best For
Knowledge sharing, concept explanations, SaaS content, productivity tips
@@ -1,23 +0,0 @@
# pop
Vibrant, energetic, eye-catching
## Color Palette
- Primary: Bright red (#F56565), yellow (#ECC94B), blue (#4299E1), green (#48BB78)
- Background: White (#FFFFFF), light gray
- Accents: Neon pink, electric purple
## Visual Elements
- Bold shapes, speech bubbles
- Comic-style effects, starburst
- Dynamic, energetic compositions
## Typography
- Dynamic, energetic hand lettering with outlines
## Best For
Exciting announcements, fun facts, engaging tutorials
@@ -1,23 +0,0 @@
# retro
Vintage, nostalgic, trendy
## Color Palette
- Primary: Muted orange, dusty pink (#FED7E2 at 70%), faded teal
- Background: Aged paper (#F5E6D3), sepia tones
- Accents: Faded red, vintage gold
## Visual Elements
- Halftone dots, vintage badges
- Classic icons, tape effects
- Aged texture overlays
## Typography
- Vintage-style hand lettering, classic feel
## Best For
Throwback content, classic tips, timeless advice
@@ -1,23 +0,0 @@
# warm
Cozy, friendly, approachable
## Color Palette
- Primary: Warm orange (#ED8936), golden yellow (#F6AD55), terracotta (#C05621)
- Background: Cream (#FFFAF0), soft peach (#FED7AA)
- Accents: Deep brown (#744210), soft red
## Visual Elements
- Sun rays, coffee cups, cozy items
- Warm lighting effects
- Friendly, inviting decorations
## Typography
- Friendly, rounded hand lettering
## Best For
Personal stories, life lessons, emotional content
@@ -25,9 +25,9 @@ Unlike other platforms, Xiaohongshu content must prioritize:
| Type | Characteristics | Best Style | Best Layout | | Type | Characteristics | Best Style | Best Layout |
|------|----------------|------------|-------------| |------|----------------|------------|-------------|
| 种草/安利 | Product recommendation, benefits focus | cute/fresh | balanced/list | | 种草/安利 | Product recommendation, benefits focus | cute/fresh | balanced/list |
| 干货分享 | Knowledge, tips, how-to | notion/tech | dense/list | | 干货分享 | Knowledge, tips, how-to | notion | dense/list |
| 个人故事 | Personal experience, emotional | warm | balanced | | 个人故事 | Personal experience, emotional | warm | balanced |
| 测评对比 | Review, comparison, pros/cons | tech/bold | comparison | | 测评对比 | Review, comparison, pros/cons | bold/notion | comparison |
| 教程步骤 | Step-by-step guide | fresh/notion | flow/list | | 教程步骤 | Step-by-step guide | fresh/notion | flow/list |
| 避坑指南 | Warnings, mistakes to avoid | bold | list/comparison | | 避坑指南 | Warnings, mistakes to avoid | bold | list/comparison |
| 清单合集 | Collections, recommendations | cute/minimal | list/dense | | 清单合集 | Collections, recommendations | cute/minimal | list/dense |
@@ -55,10 +55,10 @@ Evaluate title/hook potential using these patterns:
| Audience | Interests | Preferred Style | Content Focus | | Audience | Interests | Preferred Style | Content Focus |
|----------|-----------|-----------------|---------------| |----------|-----------|-----------------|---------------|
| 学生党 | 省钱、学习、校园 | cute/fresh | 平价、教程、学习方法 | | 学生党 | 省钱、学习、校园 | cute/fresh | 平价、教程、学习方法 |
| 打工人 | 效率、职场、减压 | minimal/tech | 工具、技巧、摸鱼 | | 打工人 | 效率、职场、减压 | minimal/notion | 工具、技巧、摸鱼 |
| 宝妈 | 育儿、家居、省心 | warm/fresh | 实用、安全、经验 | | 宝妈 | 育儿、家居、省心 | warm/fresh | 实用、安全、经验 |
| 精致女孩 | 美妆、穿搭、仪式感 | cute/retro | 好看、氛围、品质 | | 精致女孩 | 美妆、穿搭、仪式感 | cute/retro | 好看、氛围、品质 |
| 技术宅 | 工具、效率、极客 | tech/notion | 深度、专业、新奇 | | 技术宅 | 工具、效率、极客 | notion/chalkboard | 深度、专业、新奇 |
| 美食爱好者 | 探店、食谱、测评 | warm/pop | 好吃、简单、颜值 | | 美食爱好者 | 探店、食谱、测评 | warm/pop | 好吃、简单、颜值 |
| 旅行达人 | 攻略、打卡、小众 | fresh/retro | 省钱、避坑、拍照 | | 旅行达人 | 攻略、打卡、小众 | fresh/retro | 省钱、避坑、拍照 |
@@ -165,7 +165,7 @@ recommended_image_count: 6
## Content Signals ## Content Signals
- "AI工具" → tech + dense - "AI工具" → notion + dense
- "效率" → notion + list - "效率" → notion + list
- "干货" → minimal + dense - "干货" → minimal + dense
@@ -180,7 +180,7 @@ recommended_image_count: 6
## Recommended Approaches ## Recommended Approaches
1. **Tech + Dense** - 专业科技感,适合干货分享 (recommended) 1. **Notion + Dense** - 知识卡片风格,适合干货分享 (recommended)
2. **Notion + List** - 清爽知识卡片风格 2. **Notion + List** - 清爽知识卡片风格
3. **Minimal + Balanced** - 简约高端,适合职场人群 3. **Minimal + Balanced** - 简约高端,适合职场人群
``` ```
@@ -1,13 +1,13 @@
# Xiaohongshu Outline Template # Xiaohongshu Outline Template
Template for generating infographic series outlines. Template for generating infographic series outlines with layout specifications.
## File Naming ## File Naming
Outline files use style slug in the name: Outline files use strategy identifier in the name:
- `outline-style-tech.md` - Tech style variant - `outline-strategy-a.md` - Story-driven variant
- `outline-style-notion.md` - Notion style variant - `outline-strategy-b.md` - Information-dense variant
- `outline-style-minimal.md` - Minimal style variant - `outline-strategy-c.md` - Visual-first variant
- `outline.md` - Final selected (copied from chosen variant) - `outline.md` - Final selected (copied from chosen variant)
## Image File Naming ## Image File Naming
@@ -37,13 +37,43 @@ NN-{type}-[slug].md (in prompts/)
- Must be unique within the series - Must be unique within the series
- Keep short but descriptive (2-4 words) - Keep short but descriptive (2-4 words)
## Layout Selection Guide
### Density-Based Layouts
| Layout | When to Use | Info Points | Whitespace |
|--------|-------------|-------------|------------|
| sparse | Covers, quotes, impact statements | 1-2 | 60-70% |
| balanced | Standard content, tutorials | 3-4 | 40-50% |
| dense | Knowledge cards, cheat sheets | 5-8 | 20-30% |
### Structure-Based Layouts
| Layout | When to Use | Structure |
|--------|-------------|-----------|
| list | Rankings, checklists, steps | Numbered/bulleted vertical |
| comparison | Before/after, pros/cons | Left vs right split |
| flow | Processes, timelines | Connected nodes with arrows |
### Position-Based Recommendations
| Position | Recommended | Reasoning |
|----------|-------------|-----------|
| Cover | sparse | Maximum impact, clear title |
| Setup | balanced | Context without overwhelming |
| Core | balanced/dense/list | Match content density |
| Payoff | balanced/list | Clear takeaways |
| Ending | sparse | Clean CTA, memorable |
## Outline Format ## Outline Format
```markdown ```markdown
# Xiaohongshu Infographic Series Outline # Xiaohongshu Infographic Series Outline
--- ---
style: tech strategy: a # a, b, or c
name: Story-Driven
style: notion
default_layout: dense default_layout: dense
image_count: 6 image_count: 6
generated: YYYY-MM-DD HH:mm generated: YYYY-MM-DD HH:mm
@@ -188,16 +218,6 @@ Notion界面风格,简洁黑白配色
--- ---
``` ```
## Layout Guidelines by Position
| Position | Recommended Layout | Why |
|----------|-------------------|-----|
| Cover | `sparse` | Maximum visual impact, clear title |
| Setup | `balanced` | Context without overwhelming |
| Core | `balanced`/`dense`/`list` | Based on content density |
| Payoff | `balanced`/`list` | Clear takeaways |
| Ending | `sparse` | Clean CTA, memorable close |
## Swipe Hook Strategies ## Swipe Hook Strategies
Each image should end with a hook for the next: Each image should end with a hook for the next:
@@ -211,18 +231,17 @@ Each image should end with a hook for the next:
| Promise | "最后一个最实用👇" | | Promise | "最后一个最实用👇" |
| Urgency | "最重要的来了👇" | | Urgency | "最重要的来了👇" |
## Variant Differentiation ## Strategy Differentiation
Three variants should differ meaningfully: Three strategies should differ meaningfully:
| Aspect | Variant A | Variant B | Variant C | | Strategy | Focus | Structure | Page Count |
|--------|-----------|-----------|-----------| |----------|-------|-----------|------------|
| Style | Primary match | Alternative | Different mood | | A: Story-Driven | Emotional, personal | Hook→Problem→Discovery→Experience→Conclusion | 4-6 |
| Layout | Content-optimized | Different density | Different structure | | B: Information-Dense | Factual, structured | Core→Info Cards→Comparison→Recommendation | 3-5 |
| Tone | Professional | Casual | Playful | | C: Visual-First | Atmospheric, minimal text | Hero→Details→Lifestyle→CTA | 3-4 |
| Audience | Primary target | Secondary target | Broader appeal |
**Example for "AI工具推荐"**: **Example for "AI工具推荐"**:
- `outline-style-tech.md`: Tech + Dense - 专业极客风 - `outline-strategy-a.md`: Warm + Balanced - Personal journey with AI
- `outline-style-notion.md`: Notion + List - 清爽知识卡片 - `outline-strategy-b.md`: Notion + Dense - Knowledge card style
- `outline-style-cute.md`: Cute + Balanced - 可爱易读风 - `outline-strategy-c.md`: Minimal + Sparse - Sleek tech aesthetic
@@ -0,0 +1,279 @@
# Prompt Assembly Guide
Guide for assembling image generation prompts from elements, presets, and outline content.
## Base Prompt Structure
Every XHS infographic prompt follows this structure:
```
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: "",。!)
---
{STYLE_SECTION}
---
{LAYOUT_SECTION}
---
{CONTENT_SECTION}
---
{WATERMARK_SECTION}
---
Please use nano banana pro to generate the infographic based on the specifications above.
```
## Style Section Assembly
Load from `presets/{style}.md` and extract key elements:
```markdown
## Style: {style_name}
**Color Palette**:
- Primary: {colors}
- Background: {colors}
- Accents: {colors}
**Visual Elements**:
{visual_elements}
**Typography**:
{typography_style}
```
## Layout Section Assembly
Load from `elements/canvas.md` and extract relevant layout:
```markdown
## Layout: {layout_name}
**Information Density**: {density}
**Whitespace**: {percentage}
**Structure**:
{structure_description}
**Visual Balance**:
{balance_description}
```
## Content Section Assembly
From outline entry:
```markdown
## Content
**Position**: {Cover/Content/Ending}
**Core Message**: {message}
**Text Content**:
{text_list}
**Visual Concept**:
{visual_description}
```
## Watermark Section (if enabled)
```markdown
## Watermark
Include a subtle watermark "{content}" positioned at {position}
with approximately {opacity*100}% visibility. The watermark should
be legible but not distracting from the main content.
```
## Assembly Process
### Step 1: Load Preset
```python
preset = load_preset(style_name) # e.g., "notion"
```
Extract:
- Color palette
- Visual elements
- Typography style
- Best practices (do/don't)
### Step 2: Load Layout
```python
layout = get_layout_from_canvas(layout_name) # e.g., "dense"
```
Extract:
- Information density guidelines
- Whitespace percentage
- Structure description
- Visual balance rules
### Step 3: Format Content
From outline entry, format:
- Position context (Cover/Content/Ending)
- Text content with hierarchy
- Visual concept description
- Swipe hook (for context, not in prompt)
### Step 4: Add Watermark (if applicable)
If preferences include watermark:
- Add watermark section with content, position, opacity
### Step 5: Combine
Assemble all sections into final prompt following base structure.
## Example: Assembled Prompt
```markdown
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
- 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: "",。!)
---
## Style: Notion
**Color Palette**:
- Primary: Black (#1A1A1A), dark gray (#4A4A4A)
- Background: Pure white (#FFFFFF), off-white (#FAFAFA)
- Accents: Pastel blue (#A8D4F0), pastel yellow (#F9E79F), pastel pink (#FADBD8)
**Visual Elements**:
- Simple line doodles, hand-drawn wobble effect
- Geometric shapes, stick figures
- Maximum whitespace, single-weight ink lines
- Clean, uncluttered compositions
**Typography**:
- Clean hand-drawn lettering
- Simple sans-serif labels
- Minimal decoration on text
---
## Layout: Dense
**Information Density**: High (5-8 key points)
**Whitespace**: 20-30% of canvas
**Structure**:
- Multiple sections, structured grid
- More text, compact but organized
- Title + multiple sections with headers + numerous points
**Visual Balance**:
- Organized grid structure
- Clear section boundaries
- Compact but readable spacing
---
## Content
**Position**: Content (Page 3 of 6)
**Core Message**: ChatGPT使用技巧
**Text Content**:
- Title: 「ChatGPT」
- Subtitle: 最强AI助手
- Points:
- 写文案:给出框架,秒出初稿
- 改文章:润色、翻译、总结
- 编程:写代码、找bug
- 学习:解释概念、出题练习
**Visual Concept**:
ChatGPT logo居中,四周放射状展示功能点
深色科技背景,霓虹绿点缀
---
## Watermark
Include a subtle watermark "@myxhsaccount" positioned at bottom-right
with approximately 50% visibility. The watermark should
be legible but not distracting from the main content.
---
Please use nano banana pro to generate the infographic based on the specifications above.
```
## Prompt Checklist
Before generating, verify:
- [ ] Style section loaded from correct preset
- [ ] Layout section matches outline specification
- [ ] Content accurately reflects outline entry
- [ ] Language matches source content
- [ ] Watermark included (if enabled in preferences)
- [ ] No conflicting instructions