mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-10 05:01:40 +00:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e736707628 | |||
| d863f11f61 | |||
| 2ce873c65c | |||
| 964cf1e045 | |||
| eded9a98bb | |||
| a64fdbd23f | |||
| 36b9c5e197 | |||
| 851497abbd | |||
| 65a561e654 | |||
| 7b2c02a007 | |||
| 98f49eae96 | |||
| 1bdf44df9e | |||
| d59ecf22c1 | |||
| 22fa1a62fc | |||
| 3d48eed33a | |||
| 8f1c4a65dd | |||
| 9b97720f16 | |||
| 145e1d2d04 | |||
| 49403b6fab | |||
| 6d56f1dae8 | |||
| dd08a2aa89 | |||
| d9b47debb3 | |||
| 6cc8627ca8 | |||
| 54103dfd7d | |||
| 6012b9cc88 | |||
| 260b71cdd7 | |||
| 9ff468a6a7 | |||
| c83d114a73 | |||
| e964100dd9 | |||
| 6c0ae7a86a | |||
| 52ade637af | |||
| 569beebdd6 | |||
| 6cbf0f4e52 | |||
| 5615e6150f | |||
| 7465f37dcf | |||
| 38a1b90db3 | |||
| b53a5db60a | |||
| 372e03cef3 | |||
| ea8ad82786 | |||
| 6fffe252c0 | |||
| 86a84739e8 | |||
| 7f80100b3e | |||
| 61960961ba | |||
| cdd9ee042d | |||
| c742bfa1af | |||
| 0faea4ecaa | |||
| 7c9ec259a1 | |||
| 24a17709a8 | |||
| a3849af0cf | |||
| cc95e6fe05 | |||
| e7d9ed7917 | |||
| 876c6332f6 |
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "1.28.3"
|
||||
"version": "1.35.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
+113
@@ -2,6 +2,119 @@
|
||||
|
||||
English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 1.35.0 - 2026-02-24
|
||||
|
||||
### Features
|
||||
- `baoyu-image-gen`: add Replicate provider support with configurable models (by @justnode)
|
||||
- `baoyu-infographic`: add `dense-modules` layout and 3 new styles (`morandi-journal`, `pop-laboratory`, `retro-pop-grid`) for high-density infographics. Add keyword shortcuts for auto-selection. Prompt credit: [AJ](https://waytoagi.feishu.cn/wiki/YG0zwalijihRREkgmPzcWRInnUg)
|
||||
|
||||
### Documentation
|
||||
- `baoyu-image-gen`: add Replicate model configuration documentation
|
||||
|
||||
## 1.34.2 - 2026-02-25
|
||||
|
||||
### Documentation
|
||||
- `baoyu-markdown-to-html`: clarify theme resolution order with local and cross-skill EXTEND.md fallbacks before prompting user.
|
||||
- `baoyu-post-to-wechat`: align markdown conversion theme handling with deterministic fallback (`CLI --theme` -> EXTEND.md `default_theme` -> `default`) and require explicit `--theme` parameter.
|
||||
|
||||
## 1.34.1 - 2026-02-20
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-wechat`: fix upload progress check crashing on second iteration (by @LyInfi)
|
||||
|
||||
## 1.34.0 - 2026-02-17
|
||||
|
||||
### Features
|
||||
- `baoyu-xhs-images`: add reference image chain for visual consistency across multi-image series (by @jeffrey94)
|
||||
|
||||
### Refactor
|
||||
- `baoyu-article-illustrator`: enforce prompt file creation as blocking step before image generation, add structured prompt quality requirements (ZONES / LABELS / COLORS / STYLE / ASPECT) and verification checklist.
|
||||
|
||||
## 1.33.1 - 2026-02-14
|
||||
|
||||
### Refactor
|
||||
- `baoyu-post-to-x`: replace hand-rolled markdown parser with marked ecosystem for X Articles HTML conversion.
|
||||
|
||||
### Documentation
|
||||
- `baoyu-post-to-x`: remove `--submit` flag from all scripts; clarify that scripts only fill content into browser for manual review and publish.
|
||||
|
||||
## 1.33.0 - 2026-02-13
|
||||
|
||||
### Features
|
||||
- `baoyu-post-to-x`: add pre-flight environment check script (`check-paste-permissions.ts`); add troubleshooting section for Chrome debug port conflicts; replace fixed sleep with image upload verification polling up to 15s.
|
||||
- `baoyu-post-to-wechat`: add pre-flight environment check script (`check-permissions.ts`) covering Chrome, profile isolation, Bun, Accessibility, clipboard, paste keystroke, API credentials.
|
||||
|
||||
## 1.32.0 - 2026-02-12
|
||||
|
||||
### Features
|
||||
- `baoyu-danger-x-to-markdown`: add `--download-media` flag to download images/videos locally and rewrite markdown links to relative paths; add media localization module; add first-time setup with EXTEND.md preferences; add `coverImage` to frontmatter output.
|
||||
|
||||
### Refactor
|
||||
- `baoyu-danger-x-to-markdown`: use camelCase for frontmatter keys (`tweetCount`, `coverImage`, `requestedUrl`, etc.).
|
||||
- `baoyu-format-markdown`: rename `featureImage` to `coverImage` as primary frontmatter key (with `featureImage` as accepted alias).
|
||||
- `baoyu-post-to-wechat`: prioritize `coverImage` over `featureImage` in cover image frontmatter lookup order.
|
||||
|
||||
## 1.31.2 - 2026-02-10
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-wechat`: fix PowerShell clipboard copy failing on Windows due to `param()`/`-Path` not working with `-Command`.
|
||||
- `baoyu-post-to-x`: fix PowerShell clipboard copy on Windows (same issue); fix `getScriptDir()` returning invalid path on Windows (`/C:/...` prefix).
|
||||
|
||||
## 1.31.1 - 2026-02-10
|
||||
|
||||
### Features
|
||||
- `baoyu-post-to-wechat`: adapt to new WeChat UI — rename 图文 to 贴图; add ProseMirror editor support with old editor fallback; add fallback file input selector; add upload progress monitoring; improve save button detection with toast verification.
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-wechat`: truncate digest > 120 chars at punctuation boundary; fix cover image relative path resolution.
|
||||
- `baoyu-post-to-x`: fix Chrome launch on macOS via `open -na`; fix cover image relative path resolution.
|
||||
|
||||
## 1.31.0 - 2026-02-07
|
||||
|
||||
### Features
|
||||
- `baoyu-post-to-wechat`: add comment control settings (`need_open_comment`, `only_fans_can_comment`); add cover image fallback chain (CLI → frontmatter → `imgs/cover.png` → first inline image); add author resolution priority; add first-time setup flow with EXTEND.md preferences.
|
||||
|
||||
## 1.30.3 - 2026-02-06
|
||||
|
||||
### Refactor
|
||||
- `baoyu-article-illustrator`: optimize SKILL.md from 197 to 150 lines (24% reduction); apply progressive disclosure pattern with concise overview and detailed references.
|
||||
|
||||
## 1.30.2 - 2026-02-06
|
||||
|
||||
### Refactor
|
||||
- `baoyu-cover-image`: optimize SKILL.md from 532 to 233 lines (56% reduction); extract reference image handling to `references/workflow/reference-images.md`; condense galleries to value-only tables with links.
|
||||
|
||||
## 1.30.1 - 2026-02-06
|
||||
|
||||
### Features
|
||||
- `baoyu-image-gen`: add OpenAI GPT Image edits support for reference images (`--ref`); auto-select Google or OpenAI when ref provided.
|
||||
|
||||
### Fixes
|
||||
- `baoyu-image-gen`: change ref-related warnings to explicit errors with fix hints; add reference image validation.
|
||||
- `baoyu-cover-image`: enhance reference image analysis with deep extraction template; require MUST INCORPORATE section for concrete visual elements.
|
||||
|
||||
## 1.30.0 - 2026-02-06
|
||||
|
||||
### Features
|
||||
- `baoyu-cover-image`: add font dimension with 4 typography styles (clean, handwritten, serif, display); includes auto-selection rules, compatibility matrix, and `warm-flat` style preset.
|
||||
|
||||
## 1.29.0 - 2026-02-06
|
||||
|
||||
### Features
|
||||
- `baoyu-image-gen`: add EXTEND.md configuration support, including schema documentation and runtime preference loading in scripts (by @kingdomad).
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-wechat`: fix duplicated title and ordered-list numbering in WeChat article publishing (by @NantesCheval).
|
||||
- `baoyu-url-to-markdown`: replace regex-only conversion with multi-strategy content extraction and Turndown conversion; improve noise filtering for Substack-style pages.
|
||||
|
||||
## 1.28.4 - 2026-02-03
|
||||
|
||||
### Features
|
||||
- `baoyu-markdown-to-html`: add author and description meta tags to generated HTML from YAML frontmatter; strip quotes from frontmatter values (supports both English and Chinese quotation marks).
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-wechat`: remove extra empty lines after image paste; fix summary field timing to fill after content paste (prevents being overwritten).
|
||||
|
||||
## 1.28.3 - 2026-02-03
|
||||
|
||||
### Fixes
|
||||
|
||||
+113
@@ -2,6 +2,119 @@
|
||||
|
||||
[English](./CHANGELOG.md) | 中文
|
||||
|
||||
## 1.35.0 - 2026-02-24
|
||||
|
||||
### 新功能
|
||||
- `baoyu-image-gen`:新增 Replicate 图片生成服务,支持自定义模型配置 (by @justnode)
|
||||
- `baoyu-infographic`:新增 `dense-modules` 高密度模块布局及 3 种新风格(`morandi-journal`、`pop-laboratory`、`retro-pop-grid`),支持关键词快捷选择。高密度信息大图提示词来自 [AJ](https://waytoagi.feishu.cn/wiki/YG0zwalijihRREkgmPzcWRInnUg)
|
||||
|
||||
### 文档
|
||||
- `baoyu-image-gen`:补充 Replicate 模型配置说明文档
|
||||
|
||||
## 1.34.2 - 2026-02-25
|
||||
|
||||
### 文档
|
||||
- `baoyu-markdown-to-html`:明确主题解析优先级,先读取本技能与跨技能 EXTEND.md 的 `default_theme`,仅在未命中时询问用户。
|
||||
- `baoyu-post-to-wechat`:统一 markdown 转 HTML 的主题解析回退链(CLI `--theme` -> EXTEND.md `default_theme` -> `default`),并强制始终显式传入 `--theme` 参数。
|
||||
|
||||
## 1.34.1 - 2026-02-20
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-wechat`:修复上传进度检查在第二次迭代时崩溃的问题 (by @LyInfi)
|
||||
|
||||
## 1.34.0 - 2026-02-17
|
||||
|
||||
### 新功能
|
||||
- `baoyu-xhs-images`:新增参考图片链功能,确保多图系列的视觉一致性 (by @jeffrey94)
|
||||
|
||||
### 重构
|
||||
- `baoyu-article-illustrator`:将提示词文件创建设为生成图片前的阻断步骤,新增结构化提示词质量要求(ZONES / LABELS / COLORS / STYLE / ASPECT)和验证清单。
|
||||
|
||||
## 1.33.1 - 2026-02-14
|
||||
|
||||
### 重构
|
||||
- `baoyu-post-to-x`:将手写 markdown 解析器替换为 marked 生态系统,用于 X Articles HTML 转换。
|
||||
|
||||
### 文档
|
||||
- `baoyu-post-to-x`:移除所有脚本的 `--submit` 参数;明确脚本仅将内容填充到浏览器,由用户手动审核和发布。
|
||||
|
||||
## 1.33.0 - 2026-02-13
|
||||
|
||||
### 新功能
|
||||
- `baoyu-post-to-x`:新增环境预检脚本(`check-paste-permissions.ts`);新增 Chrome 调试端口冲突的故障排查说明;将固定等待替换为图片上传轮询验证(最长 15 秒)。
|
||||
- `baoyu-post-to-wechat`:新增环境预检脚本(`check-permissions.ts`),检查 Chrome、配置文件隔离、Bun、辅助功能、剪贴板、粘贴按键和 API 凭据。
|
||||
|
||||
## 1.32.0 - 2026-02-12
|
||||
|
||||
### 新功能
|
||||
- `baoyu-danger-x-to-markdown`:新增 `--download-media` 参数,支持将图片/视频下载到本地并将 markdown 链接改写为相对路径;新增媒体本地化模块;新增首次使用 EXTEND.md 偏好设置;在 frontmatter 中输出 `coverImage`。
|
||||
|
||||
### 重构
|
||||
- `baoyu-danger-x-to-markdown`:frontmatter 字段改为 camelCase(`tweetCount`、`coverImage`、`requestedUrl` 等)。
|
||||
- `baoyu-format-markdown`:将主 frontmatter 字段从 `featureImage` 更名为 `coverImage`(兼容 `featureImage`)。
|
||||
- `baoyu-post-to-wechat`:封面图片 frontmatter 查找顺序中优先使用 `coverImage`。
|
||||
|
||||
## 1.31.2 - 2026-02-10
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-wechat`:修复 Windows 上 PowerShell 剪贴板复制失败的问题(`param()`/`-Path` 与 `-Command` 参数不兼容)。
|
||||
- `baoyu-post-to-x`:修复 Windows 上 PowerShell 剪贴板复制(同上);修复 `getScriptDir()` 在 Windows 上返回无效路径(`/C:/...` 前缀)。
|
||||
|
||||
## 1.31.1 - 2026-02-10
|
||||
|
||||
### 新功能
|
||||
- `baoyu-post-to-wechat`:适配微信新版 UI — 图文更名为贴图;新增 ProseMirror 编辑器支持(兼容旧版编辑器);新增备用文件上传选择器;新增上传进度监控;改进保存按钮检测并增加 toast 验证。
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-wechat`:摘要超过 120 字符时在标点处截断;修复封面图片相对路径解析。
|
||||
- `baoyu-post-to-x`:修复 macOS 上 Chrome 启动问题(使用 `open -na`);修复封面图片相对路径解析。
|
||||
|
||||
## 1.31.0 - 2026-02-07
|
||||
|
||||
### 新功能
|
||||
- `baoyu-post-to-wechat`:新增评论控制设置(`need_open_comment`、`only_fans_can_comment`);新增封面图片回退链(CLI → frontmatter → `imgs/cover.png` → 首张内联图片);新增作者优先级解析;新增首次使用引导流程和 EXTEND.md 偏好配置。
|
||||
|
||||
## 1.30.3 - 2026-02-06
|
||||
|
||||
### 重构
|
||||
- `baoyu-article-illustrator`:优化 SKILL.md 从 197 行精简至 150 行(减少 24%);采用渐进式披露模式,主文件提供简洁概览,详细内容通过引用文件提供。
|
||||
|
||||
## 1.30.2 - 2026-02-06
|
||||
|
||||
### 重构
|
||||
- `baoyu-cover-image`:优化 SKILL.md 从 532 行精简至 233 行(减少 56%);将参考图片处理流程提取到 `references/workflow/reference-images.md`;画廊改为纯值表格并链接到详细参考文件。
|
||||
|
||||
## 1.30.1 - 2026-02-06
|
||||
|
||||
### 新功能
|
||||
- `baoyu-image-gen`:新增 OpenAI GPT Image edits 支持参考图片(`--ref`);提供 ref 时自动选择 Google 或 OpenAI。
|
||||
|
||||
### 修复
|
||||
- `baoyu-image-gen`:将 ref 相关警告改为明确错误提示;新增参考图片验证。
|
||||
- `baoyu-cover-image`:增强参考图片分析,使用深度提取模板;要求 MUST INCORPORATE 章节以包含具体可复现的视觉元素。
|
||||
|
||||
## 1.30.0 - 2026-02-06
|
||||
|
||||
### 新功能
|
||||
- `baoyu-cover-image`:新增字体维度,支持 4 种字体风格(clean、handwritten、serif、display);包含自动选择规则、兼容性矩阵和 `warm-flat` 风格预设。
|
||||
|
||||
## 1.29.0 - 2026-02-06
|
||||
|
||||
### 新功能
|
||||
- `baoyu-image-gen`:新增 EXTEND.md 配置支持,补充配置 schema 文档并在脚本运行时读取偏好设置 (by @kingdomad)。
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-wechat`:修复公众号文章发布时标题和有序列表编号重复问题 (by @NantesCheval)。
|
||||
- `baoyu-url-to-markdown`:将正则转换升级为多策略正文抽取 + Turndown 转换,提升 Substack 类页面的噪声过滤能力。
|
||||
|
||||
## 1.28.4 - 2026-02-03
|
||||
|
||||
### 新功能
|
||||
- `baoyu-markdown-to-html`:从 YAML frontmatter 生成 author 和 description meta 标签;自动去除 frontmatter 值两端的引号(支持中英文引号)。
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-wechat`:移除图片粘贴后产生的多余空行;修复摘要填充时机,改为内容粘贴后填写(避免被覆盖)。
|
||||
|
||||
## 1.28.3 - 2026-02-03
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -519,12 +519,12 @@ Post content and articles to X (Twitter). Supports regular posts with images and
|
||||
|
||||
Post content to WeChat Official Account (微信公众号). Two modes available:
|
||||
|
||||
**Image-Text (图文)** - Multiple images with short title/content:
|
||||
**Image-Text (贴图)** - Multiple images with short title/content:
|
||||
|
||||
```bash
|
||||
/baoyu-post-to-wechat 图文 --markdown article.md --images ./photos/
|
||||
/baoyu-post-to-wechat 图文 --markdown article.md --image img1.png --image img2.png --image img3.png
|
||||
/baoyu-post-to-wechat 图文 --title "标题" --content "内容" --image img1.png --submit
|
||||
/baoyu-post-to-wechat 贴图 --markdown article.md --images ./photos/
|
||||
/baoyu-post-to-wechat 贴图 --markdown article.md --image img1.png --image img2.png --image img3.png
|
||||
/baoyu-post-to-wechat 贴图 --title "标题" --content "内容" --image img1.png --submit
|
||||
```
|
||||
|
||||
**Article (文章)** - Full markdown/HTML with rich formatting:
|
||||
@@ -681,6 +681,9 @@ Converts X (Twitter) content to markdown format. Supports tweet threads and X Ar
|
||||
|
||||
# JSON output
|
||||
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --json
|
||||
|
||||
# Download media (images/videos) to local files
|
||||
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --download-media
|
||||
```
|
||||
|
||||
**Supported URLs:**
|
||||
@@ -713,7 +716,7 @@ Format plain text or markdown files with proper frontmatter, titles, summaries,
|
||||
|
||||
**Workflow**:
|
||||
1. Read source file and analyze content structure
|
||||
2. Check/create YAML frontmatter (title, slug, summary, featureImage)
|
||||
2. Check/create YAML frontmatter (title, slug, summary, coverImage)
|
||||
3. Handle title: use existing, extract from H1, or generate candidates
|
||||
4. Apply formatting: headings, bold, lists, code blocks, quotes
|
||||
5. Save to `{filename}-formatted.md`
|
||||
@@ -725,7 +728,7 @@ Format plain text or markdown files with proper frontmatter, titles, summaries,
|
||||
| `title` | Use existing, extract H1, or generate candidates |
|
||||
| `slug` | Infer from file path or generate from title |
|
||||
| `summary` | Generate engaging summary (100-150 chars) |
|
||||
| `featureImage` | Check for `imgs/cover.png` in same directory |
|
||||
| `coverImage` | Check for `imgs/cover.png` in same directory |
|
||||
|
||||
**Formatting Rules**:
|
||||
| Element | Format |
|
||||
|
||||
+9
-6
@@ -519,12 +519,12 @@ npx skills add jimliu/baoyu-skills
|
||||
|
||||
发布内容到微信公众号,支持两种模式:
|
||||
|
||||
**图文模式** - 多图配短标题和正文:
|
||||
**贴图模式** - 多图配短标题和正文:
|
||||
|
||||
```bash
|
||||
/baoyu-post-to-wechat 图文 --markdown article.md --images ./photos/
|
||||
/baoyu-post-to-wechat 图文 --markdown article.md --image img1.png --image img2.png --image img3.png
|
||||
/baoyu-post-to-wechat 图文 --title "标题" --content "内容" --image img1.png --submit
|
||||
/baoyu-post-to-wechat 贴图 --markdown article.md --images ./photos/
|
||||
/baoyu-post-to-wechat 贴图 --markdown article.md --image img1.png --image img2.png --image img3.png
|
||||
/baoyu-post-to-wechat 贴图 --title "标题" --content "内容" --image img1.png --submit
|
||||
```
|
||||
|
||||
**文章模式** - 完整 markdown/HTML 富文本格式:
|
||||
@@ -681,6 +681,9 @@ AI 驱动的生成后端。
|
||||
|
||||
# JSON 输出
|
||||
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --json
|
||||
|
||||
# 下载媒体文件(图片/视频)到本地
|
||||
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --download-media
|
||||
```
|
||||
|
||||
**支持的 URL:**
|
||||
@@ -713,7 +716,7 @@ AI 驱动的生成后端。
|
||||
|
||||
**工作流程**:
|
||||
1. 读取源文件并分析内容结构
|
||||
2. 检查/创建 YAML frontmatter(title、slug、summary、featureImage)
|
||||
2. 检查/创建 YAML frontmatter(title、slug、summary、coverImage)
|
||||
3. 处理标题:使用现有标题、提取 H1 或生成候选标题
|
||||
4. 应用格式:层级标题、加粗、列表、代码块、引用
|
||||
5. 保存为 `{文件名}-formatted.md`
|
||||
@@ -725,7 +728,7 @@ AI 驱动的生成后端。
|
||||
| `title` | 使用现有、提取 H1 或生成候选 |
|
||||
| `slug` | 从文件路径推断或根据标题生成 |
|
||||
| `summary` | 生成吸引人的摘要(100-150 字) |
|
||||
| `featureImage` | 检查同目录下 `imgs/cover.png` |
|
||||
| `coverImage` | 检查同目录下 `imgs/cover.png` |
|
||||
|
||||
**格式化规则**:
|
||||
| 元素 | 格式 |
|
||||
|
||||
@@ -11,55 +11,34 @@ Analyze articles, identify illustration positions, generate images with Type ×
|
||||
|
||||
| Dimension | Controls | Examples |
|
||||
|-----------|----------|----------|
|
||||
| **Type** | Information structure, layout | infographic, scene, flowchart, comparison, framework, timeline |
|
||||
| **Style** | Visual aesthetics, mood | notion, warm, minimal, blueprint, watercolor, elegant |
|
||||
| **Type** | Information structure | infographic, scene, flowchart, comparison, framework, timeline |
|
||||
| **Style** | Visual aesthetics | notion, warm, minimal, blueprint, watercolor, elegant |
|
||||
|
||||
Type × Style can be freely combined. Example: `--type infographic --style blueprint`
|
||||
Combine freely: `--type infographic --style blueprint`
|
||||
|
||||
## Illustration Purpose
|
||||
|
||||
Auto-detected during content analysis. Influences type/style recommendations.
|
||||
|
||||
| Purpose | Description | Best Types |
|
||||
|---------|-------------|------------|
|
||||
| **information** | Help understand abstract concepts | infographic, flowchart, comparison |
|
||||
| **visualization** | Turn abstract ideas into concrete visuals | framework, comparison, infographic |
|
||||
| **imagination** | Create atmosphere, spark imagination | scene, timeline |
|
||||
|
||||
## Type Gallery
|
||||
## Types
|
||||
|
||||
| Type | Best For |
|
||||
|------|----------|
|
||||
| `infographic` | Data, metrics, technical articles |
|
||||
| `scene` | Narratives, personal stories, emotional content |
|
||||
| `flowchart` | Tutorials, workflows, processes |
|
||||
| `comparison` | Side-by-side, before/after, options |
|
||||
| `framework` | Methodologies, models, architecture |
|
||||
| `timeline` | History, progress, evolution |
|
||||
| `infographic` | Data, metrics, technical |
|
||||
| `scene` | Narratives, emotional |
|
||||
| `flowchart` | Processes, workflows |
|
||||
| `comparison` | Side-by-side, options |
|
||||
| `framework` | Models, architecture |
|
||||
| `timeline` | History, evolution |
|
||||
|
||||
## Styles
|
||||
|
||||
See [references/styles.md](references/styles.md) for:
|
||||
- **Core Styles**: Simplified tier for quick selection (vector, minimal-flat, sci-fi, hand-drawn, editorial, scene)
|
||||
- **Style Gallery**: Full 20+ style options with descriptions
|
||||
- **Auto Selection**: Content signals → Type/Style recommendations
|
||||
- **Compatibility Matrix**: Type × Style combinations
|
||||
See [references/styles.md](references/styles.md) for Core Styles, full gallery, and Type × Style compatibility.
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
Progress:
|
||||
- [ ] Step 1: Pre-check
|
||||
- [ ] 1.5 Load preferences (EXTEND.md) ⛔ BLOCKING
|
||||
- [ ] 1.0 Reference images ⚠️ (if provided)
|
||||
- [ ] 1.2-1.4 Config questions (1 AskUserQuestion, max 4 Qs)
|
||||
- [ ] Step 2: Setup & Analyze
|
||||
- [ ] Step 3: Confirm Settings (1 AskUserQuestion, max 4 Qs)
|
||||
- [ ] Q1: Type ⚠️
|
||||
- [ ] Q2: Density ⚠️ MUST ASK
|
||||
- [ ] Q3: Style ⚠️
|
||||
- [ ] Step 4: Generate Outline
|
||||
- [ ] Step 5: Generate Images
|
||||
- [ ] Step 1: Pre-check (EXTEND.md, references, config)
|
||||
- [ ] Step 2: Analyze content
|
||||
- [ ] Step 3: Confirm settings (AskUserQuestion)
|
||||
- [ ] Step 4: Generate outline
|
||||
- [ ] Step 5: Generate images
|
||||
- [ ] Step 6: Finalize
|
||||
```
|
||||
|
||||
@@ -67,8 +46,6 @@ Progress:
|
||||
|
||||
**1.5 Load Preferences (EXTEND.md) ⛔ BLOCKING**
|
||||
|
||||
**CRITICAL**: If EXTEND.md not found, MUST complete first-time setup before ANY other steps.
|
||||
|
||||
```bash
|
||||
test -f .baoyu-skills/baoyu-article-illustrator/EXTEND.md && echo "project"
|
||||
test -f "$HOME/.baoyu-skills/baoyu-article-illustrator/EXTEND.md" && echo "user"
|
||||
@@ -76,122 +53,103 @@ test -f "$HOME/.baoyu-skills/baoyu-article-illustrator/EXTEND.md" && echo "user"
|
||||
|
||||
| Result | Action |
|
||||
|--------|--------|
|
||||
| Found | Read, parse, display summary → Continue |
|
||||
| Not found | ⛔ Run first-time setup ([references/config/first-time-setup.md](references/config/first-time-setup.md)) |
|
||||
|
||||
**Supports**: Watermark | Preferred type/style | Custom styles | Language | Output directory
|
||||
|
||||
**1.0-1.4**: Handle reference images, determine input type, ask config questions.
|
||||
| Found | Read, parse, display summary |
|
||||
| Not found | ⛔ Run [first-time-setup](references/config/first-time-setup.md) |
|
||||
|
||||
Full procedures: [references/workflow.md](references/workflow.md#step-1-pre-check)
|
||||
|
||||
---
|
||||
### Step 2: Analyze
|
||||
|
||||
### Step 2: Setup & Analyze
|
||||
|
||||
| Analysis | Description |
|
||||
|----------|-------------|
|
||||
| Analysis | Output |
|
||||
|----------|--------|
|
||||
| Content type | Technical / Tutorial / Methodology / Narrative |
|
||||
| Illustration purpose | information / visualization / imagination |
|
||||
| Core arguments | 2-5 main points to visualize |
|
||||
| Visual opportunities | Positions where illustrations add value |
|
||||
| Purpose | information / visualization / imagination |
|
||||
| Core arguments | 2-5 main points |
|
||||
| Positions | Where illustrations add value |
|
||||
|
||||
**CRITICAL**: If article uses metaphors, do NOT illustrate literally. Visualize the **underlying concept**.
|
||||
**CRITICAL**: Metaphors → visualize underlying concept, NOT literal image.
|
||||
|
||||
Full procedures: [references/workflow.md](references/workflow.md#step-2-setup--analyze)
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Confirm Settings ⚠️
|
||||
|
||||
**Do NOT skip.** Use ONE AskUserQuestion call with max 4 questions. **Q1, Q2, Q3 are ALL REQUIRED.**
|
||||
**ONE AskUserQuestion, max 4 Qs. Q1-Q3 REQUIRED.**
|
||||
|
||||
| Question | Options |
|
||||
|----------|---------|
|
||||
| **Q1: Type** ⚠️ | [Recommended], infographic, scene, flowchart, comparison, framework, timeline, mixed |
|
||||
| **Q2: Density** ⚠️ | minimal (1-2), balanced (3-5), per-section (Recommended), rich (6+) |
|
||||
| **Q3: Style** ⚠️ | [Recommended], minimal-flat, sci-fi, hand-drawn, editorial, scene, Other |
|
||||
| **Q4: Language** | When article language ≠ EXTEND.md setting |
|
||||
| Q | Options |
|
||||
|---|---------|
|
||||
| **Q1: Type** | [Recommended], infographic, scene, flowchart, comparison, framework, timeline, mixed |
|
||||
| **Q2: Density** | minimal (1-2), balanced (3-5), per-section (Recommended), rich (6+) |
|
||||
| **Q3: Style** | [Recommended], minimal-flat, sci-fi, hand-drawn, editorial, scene, Other |
|
||||
| Q4: Language | When article language ≠ EXTEND.md setting |
|
||||
|
||||
Full procedures: [references/workflow.md](references/workflow.md#step-3-confirm-settings-)
|
||||
|
||||
---
|
||||
|
||||
### Step 4: Generate Outline
|
||||
|
||||
Save as `outline.md` with frontmatter (type, density, style, image_count, references) and illustration entries:
|
||||
Save `outline.md` with frontmatter (type, density, style, image_count) and entries:
|
||||
|
||||
```yaml
|
||||
## Illustration 1
|
||||
**Position**: [section] / [paragraph]
|
||||
**Purpose**: [why this helps]
|
||||
**Visual Content**: [what to show]
|
||||
**Position**: [section/paragraph]
|
||||
**Purpose**: [why]
|
||||
**Visual Content**: [what]
|
||||
**Filename**: 01-infographic-concept-name.png
|
||||
```
|
||||
|
||||
Full template: [references/workflow.md](references/workflow.md#step-4-generate-outline)
|
||||
|
||||
---
|
||||
|
||||
### Step 5: Generate Images
|
||||
|
||||
1. **Create Prompts**: Follow [references/prompt-construction.md](references/prompt-construction.md)
|
||||
2. **Select Generation Skill**: Check available skills
|
||||
3. **Process References**: Handle `direct`/`style`/`palette` usage
|
||||
4. **Apply Watermark**: If enabled in EXTEND.md
|
||||
5. **Generate**: Sequential, retry once on failure
|
||||
⛔ **BLOCKING: Prompt files MUST be saved before ANY image generation.**
|
||||
|
||||
1. For each illustration, create a prompt file per [references/prompt-construction.md](references/prompt-construction.md)
|
||||
2. Save to `prompts/NN-{type}-{slug}.md` with YAML frontmatter
|
||||
3. Prompts **MUST** use type-specific templates with structured sections (ZONES / LABELS / COLORS / STYLE / ASPECT)
|
||||
4. LABELS **MUST** include article-specific data: actual numbers, terms, metrics, quotes
|
||||
5. **DO NOT** pass ad-hoc inline prompts to `--prompt` without saving prompt files first
|
||||
6. Select generation skill, process references (`direct`/`style`/`palette`)
|
||||
7. Apply watermark if EXTEND.md enabled
|
||||
8. Generate from saved prompt files; retry once on failure
|
||||
|
||||
Full procedures: [references/workflow.md](references/workflow.md#step-5-generate-images)
|
||||
|
||||
---
|
||||
|
||||
### Step 6: Finalize
|
||||
|
||||
**Update Article**: Insert `` after corresponding paragraphs.
|
||||
Insert `` after paragraphs.
|
||||
|
||||
**Output Summary**:
|
||||
```
|
||||
Article Illustration Complete!
|
||||
Article: [path] | Type: [type] | Density: [level] | Style: [style]
|
||||
Images: X/N generated
|
||||
Positions:
|
||||
- 01-xxx.png → After "[Section]"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Output Directory
|
||||
|
||||
```
|
||||
illustrations/{topic-slug}/
|
||||
├── source-{slug}.{ext}
|
||||
├── references/ # Only if references provided
|
||||
│ └── NN-ref-{slug}.png
|
||||
├── references/ # if provided
|
||||
├── outline.md
|
||||
├── prompts/
|
||||
│ └── illustration-{slug}.md
|
||||
└── NN-{type}-{slug}.png
|
||||
```
|
||||
|
||||
**Slug**: 2-4 word topic in kebab-case.
|
||||
**Conflict**: Append `-YYYYMMDD-HHMMSS` if exists.
|
||||
**Slug**: 2-4 words, kebab-case. **Conflict**: append `-YYYYMMDD-HHMMSS`.
|
||||
|
||||
## Modification
|
||||
|
||||
| Action | Steps |
|
||||
|--------|-------|
|
||||
| **Edit** | Update prompt file FIRST → Regenerate → Update reference |
|
||||
| **Add** | Identify position → Create prompt → Generate → Update outline → Insert |
|
||||
| **Delete** | Delete files → Remove reference → Update outline |
|
||||
| Edit | Update prompt → Regenerate → Update reference |
|
||||
| Add | Position → Prompt → Generate → Update outline → Insert |
|
||||
| Delete | Delete files → Remove reference → Update outline |
|
||||
|
||||
## References
|
||||
|
||||
| File | Content |
|
||||
|------|---------|
|
||||
| [references/workflow.md](references/workflow.md) | Detailed workflow procedures |
|
||||
| [references/usage.md](references/usage.md) | Command syntax and options |
|
||||
| [references/styles.md](references/styles.md) | Style gallery & compatibility |
|
||||
| [references/workflow.md](references/workflow.md) | Detailed procedures |
|
||||
| [references/usage.md](references/usage.md) | Command syntax |
|
||||
| [references/styles.md](references/styles.md) | Style gallery |
|
||||
| [references/prompt-construction.md](references/prompt-construction.md) | Prompt templates |
|
||||
| `references/styles/<style>.md` | Full style specifications |
|
||||
| `references/config/preferences-schema.md` | EXTEND.md schema |
|
||||
| `references/config/first-time-setup.md` | First-time setup flow |
|
||||
| [references/config/first-time-setup.md](references/config/first-time-setup.md) | First-time setup |
|
||||
|
||||
@@ -261,10 +261,41 @@ references: # Only if references provided
|
||||
|
||||
## Step 5: Generate Images
|
||||
|
||||
### 5.1 Create Prompts
|
||||
### 5.1 Create Prompts ⛔ BLOCKING
|
||||
|
||||
Follow [prompt-construction.md](prompt-construction.md). Save to `prompts/illustration-{slug}.md`.
|
||||
- **Backup rule**: If prompt file exists, rename to `prompts/illustration-{slug}-backup-YYYYMMDD-HHMMSS.md`
|
||||
**Every illustration MUST have a saved prompt file before generation begins. DO NOT skip this step.**
|
||||
|
||||
For each illustration in the outline:
|
||||
|
||||
1. **Create prompt file**: `prompts/NN-{type}-{slug}.md`
|
||||
2. **Include YAML frontmatter**:
|
||||
```yaml
|
||||
---
|
||||
illustration_id: 01
|
||||
type: infographic
|
||||
style: custom-flat-vector
|
||||
---
|
||||
```
|
||||
3. **Follow type-specific template** from [prompt-construction.md](prompt-construction.md)
|
||||
4. **Prompt quality requirements** (all REQUIRED):
|
||||
- `Layout`: Describe overall composition (grid / radial / hierarchical / left-right / top-down)
|
||||
- `ZONES`: Describe each visual area with specific content, not vague descriptions
|
||||
- `LABELS`: Use **actual numbers, terms, metrics, quotes from the article** — NOT generic placeholders
|
||||
- `COLORS`: Specify hex codes with semantic meaning (e.g., `Coral (#E07A5F) for emphasis`)
|
||||
- `STYLE`: Describe line treatment, texture, mood, character rendering
|
||||
- `ASPECT`: Specify ratio (e.g., `16:9`)
|
||||
5. **Apply defaults**: composition requirements, character rendering, text guidelines, watermark
|
||||
6. **Backup rule**: If prompt file exists, rename to `prompts/NN-{type}-{slug}-backup-YYYYMMDD-HHMMSS.md`
|
||||
|
||||
**Verification** ⛔: Before proceeding to 5.2, confirm ALL prompt files exist:
|
||||
```
|
||||
Prompt Files:
|
||||
- prompts/01-infographic-overview.md ✓
|
||||
- prompts/02-infographic-distillation.md ✓
|
||||
...
|
||||
```
|
||||
|
||||
**DO NOT** pass ad-hoc inline text to `--prompt` without first saving prompt files. The generation command should either use `--promptfiles prompts/NN-{type}-{slug}.md` or read the saved file content for `--prompt`.
|
||||
|
||||
**CRITICAL - References in Frontmatter**:
|
||||
- Only add `references` field if files ACTUALLY EXIST in `references/` directory
|
||||
|
||||
@@ -10,156 +10,94 @@ Generate elegant cover images for articles with 5-dimensional customization.
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Auto-select all dimensions based on content
|
||||
# Auto-select dimensions based on content
|
||||
/baoyu-cover-image path/to/article.md
|
||||
|
||||
# Quick mode: skip confirmation, use auto-selection
|
||||
# Quick mode: skip confirmation
|
||||
/baoyu-cover-image article.md --quick
|
||||
|
||||
# Specify dimensions (new 5D system)
|
||||
# Specify dimensions
|
||||
/baoyu-cover-image article.md --type conceptual --palette warm --rendering flat-vector
|
||||
/baoyu-cover-image article.md --text title-subtitle --mood bold
|
||||
|
||||
# Style presets (backward-compatible shorthand for palette + rendering)
|
||||
# Style presets (shorthand for palette + rendering)
|
||||
/baoyu-cover-image article.md --style blueprint
|
||||
/baoyu-cover-image article.md --style blueprint --rendering hand-drawn # override rendering
|
||||
|
||||
# Visual only (no title text)
|
||||
/baoyu-cover-image article.md --no-title
|
||||
|
||||
# Direct content input
|
||||
/baoyu-cover-image
|
||||
[paste content]
|
||||
|
||||
# Direct input with options
|
||||
/baoyu-cover-image --palette mono --rendering digital --aspect 1:1 --quick
|
||||
[paste content]
|
||||
|
||||
# With reference images
|
||||
/baoyu-cover-image article.md --ref style-ref.png
|
||||
/baoyu-cover-image article.md --ref ref1.png ref2.png --quick
|
||||
|
||||
# Direct content input
|
||||
/baoyu-cover-image --palette mono --aspect 1:1 --quick
|
||||
[paste content]
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--type <name>` | Cover type: hero, conceptual, typography, metaphor, scene, minimal |
|
||||
| `--palette <name>` | Color palette: warm, elegant, cool, dark, earth, vivid, pastel, mono, retro |
|
||||
| `--rendering <name>` | Rendering style: flat-vector, hand-drawn, painterly, digital, pixel, chalk |
|
||||
| `--style <name>` | Preset shorthand (expands to palette + rendering, see [Style Presets](references/style-presets.md)) |
|
||||
| `--text <level>` | Text density: none, title-only, title-subtitle, text-rich |
|
||||
| `--mood <level>` | Emotional intensity: subtle, balanced, bold |
|
||||
| `--type <name>` | hero, conceptual, typography, metaphor, scene, minimal |
|
||||
| `--palette <name>` | warm, elegant, cool, dark, earth, vivid, pastel, mono, retro |
|
||||
| `--rendering <name>` | flat-vector, hand-drawn, painterly, digital, pixel, chalk |
|
||||
| `--style <name>` | Preset shorthand (see [Style Presets](references/style-presets.md)) |
|
||||
| `--text <level>` | none, title-only, title-subtitle, text-rich |
|
||||
| `--mood <level>` | subtle, balanced, bold |
|
||||
| `--font <name>` | clean, handwritten, serif, display |
|
||||
| `--aspect <ratio>` | 16:9 (default), 2.35:1, 4:3, 3:2, 1:1, 3:4 |
|
||||
| `--lang <code>` | Title language (en, zh, ja, etc.) |
|
||||
| `--no-title` | Alias for `--text none` |
|
||||
| `--quick` | Skip confirmation, use auto-selection for missing dimensions |
|
||||
| `--quick` | Skip confirmation, use auto-selection |
|
||||
| `--ref <files...>` | Reference images for style/composition guidance |
|
||||
|
||||
## Five Dimensions
|
||||
|
||||
| Dimension | Controls | Values | Default |
|
||||
|-----------|----------|--------|---------|
|
||||
| **Type** | Visual composition, information structure | hero, conceptual, typography, metaphor, scene, minimal | auto |
|
||||
| **Palette** | Colors, color scheme, decorative hints | warm, elegant, cool, dark, earth, vivid, pastel, mono, retro | auto |
|
||||
| **Rendering** | Line quality, texture, depth, element style | flat-vector, hand-drawn, painterly, digital, pixel, chalk | auto |
|
||||
| **Text** | Text density, information hierarchy | none, title-only, title-subtitle, text-rich | title-only |
|
||||
| **Mood** | Emotional intensity, visual weight | subtle, balanced, bold | balanced |
|
||||
| Dimension | Values | Default |
|
||||
|-----------|--------|---------|
|
||||
| **Type** | hero, conceptual, typography, metaphor, scene, minimal | auto |
|
||||
| **Palette** | warm, elegant, cool, dark, earth, vivid, pastel, mono, retro | auto |
|
||||
| **Rendering** | flat-vector, hand-drawn, painterly, digital, pixel, chalk | auto |
|
||||
| **Text** | none, title-only, title-subtitle, text-rich | title-only |
|
||||
| **Mood** | subtle, balanced, bold | balanced |
|
||||
| **Font** | clean, handwritten, serif, display | clean |
|
||||
|
||||
Dimensions can be freely combined. Auto-selection rules: [references/auto-selection.md](references/auto-selection.md)
|
||||
Auto-selection rules: [references/auto-selection.md](references/auto-selection.md)
|
||||
|
||||
## Type Gallery
|
||||
## Galleries
|
||||
|
||||
| Type | Description | Best For |
|
||||
|------|-------------|----------|
|
||||
| `hero` | Large visual impact, title overlay | Product launch, brand promotion, major announcements |
|
||||
| `conceptual` | Concept visualization, abstract core ideas | Technical articles, methodology, architecture design |
|
||||
| `typography` | Text-focused layout, prominent title | Opinion pieces, quotes, insights |
|
||||
| `metaphor` | Visual metaphor, concrete expressing abstract | Philosophy, growth, personal development |
|
||||
| `scene` | Atmospheric scene, narrative feel | Stories, travel, lifestyle |
|
||||
| `minimal` | Minimalist composition, generous whitespace | Zen, focus, core concepts |
|
||||
**Types**: hero, conceptual, typography, metaphor, scene, minimal
|
||||
→ Details: [references/types.md](references/types.md)
|
||||
|
||||
Type composition details: [references/types.md](references/types.md)
|
||||
**Palettes**: warm, elegant, cool, dark, earth, vivid, pastel, mono, retro
|
||||
→ Details: [references/palettes/](references/palettes/)
|
||||
|
||||
## Palette Gallery
|
||||
**Renderings**: flat-vector, hand-drawn, painterly, digital, pixel, chalk
|
||||
→ Details: [references/renderings/](references/renderings/)
|
||||
|
||||
| Palette | Vibe | Primary Colors |
|
||||
|---------|------|----------------|
|
||||
| `warm` | Friendly, approachable | Orange, golden yellow, terracotta |
|
||||
| `elegant` | Sophisticated, refined | Soft coral, muted teal, dusty rose |
|
||||
| `cool` | Technical, professional | Engineering blue, navy, cyan |
|
||||
| `dark` | Cinematic, premium | Electric purple, cyan, magenta |
|
||||
| `earth` | Natural, organic | Forest green, sage, earth brown |
|
||||
| `vivid` | Energetic, bold | Bright red, neon green, electric blue |
|
||||
| `pastel` | Gentle, whimsical | Soft pink, mint, lavender |
|
||||
| `mono` | Clean, focused | Black, near-black, white |
|
||||
| `retro` | Nostalgic, vintage | Muted orange, dusty pink, maroon |
|
||||
**Text Levels**: none (pure visual) | title-only (default) | title-subtitle | text-rich (with tags)
|
||||
→ Details: [references/dimensions/text.md](references/dimensions/text.md)
|
||||
|
||||
Palette definitions: [references/palettes/](references/palettes/)
|
||||
**Mood Levels**: subtle (low contrast) | balanced (default) | bold (high contrast)
|
||||
→ Details: [references/dimensions/mood.md](references/dimensions/mood.md)
|
||||
|
||||
## Rendering Gallery
|
||||
|
||||
| Rendering | Description | Key Characteristics |
|
||||
|-----------|-------------|---------------------|
|
||||
| `flat-vector` | Clean modern vector | Uniform outlines, flat fills, geometric icons |
|
||||
| `hand-drawn` | Sketchy organic illustration | Imperfect strokes, paper texture, doodles |
|
||||
| `painterly` | Soft watercolor/paint | Brush strokes, color bleeds, soft edges |
|
||||
| `digital` | Polished modern digital | Precise edges, subtle gradients, UI components |
|
||||
| `pixel` | Retro 8-bit pixel art | Pixel grid, dithering, chunky shapes |
|
||||
| `chalk` | Chalk on blackboard | Chalk strokes, dust effects, board texture |
|
||||
|
||||
Rendering definitions: [references/renderings/](references/renderings/)
|
||||
|
||||
## Text & Mood
|
||||
|
||||
| Text Level | Title | Subtitle | Tags | Use Case |
|
||||
|------------|:-----:|:--------:|:----:|----------|
|
||||
| `none` | - | - | - | Pure visual, no text |
|
||||
| `title-only` | ✓ | - | - | Simple headline (default) |
|
||||
| `title-subtitle` | ✓ | ✓ | - | Title + supporting context |
|
||||
| `text-rich` | ✓ | ✓ | ✓ (2-4) | Information-dense |
|
||||
|
||||
| Mood | Contrast | Saturation | Weight | Use Case |
|
||||
|------|:--------:|:----------:|:------:|----------|
|
||||
| `subtle` | Low | Muted | Light | Corporate, thought leadership |
|
||||
| `balanced` | Medium | Normal | Medium | General articles (default) |
|
||||
| `bold` | High | Vivid | Heavy | Announcements, promotions |
|
||||
|
||||
Full guides: [references/dimensions/text.md](references/dimensions/text.md) | [references/dimensions/mood.md](references/dimensions/mood.md)
|
||||
|
||||
## Style Presets & Compatibility
|
||||
|
||||
- **Style Presets**: `--style X` expands to palette + rendering. See [references/style-presets.md](references/style-presets.md)
|
||||
- **Compatibility Matrices**: Palette×Rendering, Type×Rendering, Type×Text, Type×Mood. See [references/compatibility.md](references/compatibility.md)
|
||||
- ✓✓ = highly recommended | ✓ = compatible | ✗ = not recommended
|
||||
**Fonts**: clean (sans-serif) | handwritten | serif | display (bold decorative)
|
||||
→ Details: [references/dimensions/font.md](references/dimensions/font.md)
|
||||
|
||||
## File Structure
|
||||
|
||||
Output directory depends on `default_output_dir` preference:
|
||||
|
||||
| Preference | Output Path |
|
||||
|------------|-------------|
|
||||
| `same-dir` | `{article-dir}/` |
|
||||
| `imgs-subdir` | `{article-dir}/imgs/` |
|
||||
| `independent` (default) | `cover-image/{topic-slug}/` |
|
||||
| Pasted content | `cover-image/{topic-slug}/` (always) |
|
||||
Output directory per `default_output_dir` preference:
|
||||
- `same-dir`: `{article-dir}/`
|
||||
- `imgs-subdir`: `{article-dir}/imgs/`
|
||||
- `independent` (default): `cover-image/{topic-slug}/`
|
||||
|
||||
```
|
||||
<output-dir>/
|
||||
├── source-{slug}.{ext} # Source files (text, images, etc.)
|
||||
├── source-{slug}.{ext} # Source files
|
||||
├── refs/ # Reference images (if provided)
|
||||
│ ├── ref-01-{slug}.{ext}
|
||||
│ ├── ref-01-{slug}.md # Description file (optional)
|
||||
│ ├── ref-02-{slug}.{ext}
|
||||
│ ├── ref-02-{slug}.md # Description file (optional)
|
||||
│ └── extracted-style.md # Verbally extracted style (if no file path)
|
||||
│ └── ref-01-{slug}.md # Description file
|
||||
├── prompts/cover.md # Generation prompt
|
||||
└── cover.png # Output image
|
||||
```
|
||||
|
||||
**Slug**: Extract main topic (2-4 words, kebab-case). Example: "The Future of AI" → `future-of-ai`
|
||||
**Conflict**: If directory exists, append timestamp: `{topic-slug}-YYYYMMDD-HHMMSS`
|
||||
**Source Files**: Copy all sources with naming `source-{slug}.{ext}` (multiple supported)
|
||||
**Slug**: 2-4 words, kebab-case. Conflict: append `-YYYYMMDD-HHMMSS`
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -168,20 +106,10 @@ Output directory depends on `default_output_dir` preference:
|
||||
```
|
||||
Cover Image Progress:
|
||||
- [ ] Step 0: Check preferences (EXTEND.md) ⛔ BLOCKING
|
||||
- [ ] Found → load preferences → continue
|
||||
- [ ] Not found → run first-time setup → MUST complete before Step 1
|
||||
- [ ] Step 1: Analyze content + determine output directory
|
||||
- [ ] 1.1 Reference images ⚠️ (if provided)
|
||||
- [ ] File path given → saved to refs/ ✓
|
||||
- [ ] No path → asked user OR extracted verbally
|
||||
- [ ] 1.2 Output directory determined
|
||||
- [ ] Step 2: Confirm options (5 dimensions) ⚠️ REQUIRED unless --quick or all specified
|
||||
- [ ] Step 1: Analyze content + save refs + determine output dir
|
||||
- [ ] Step 2: Confirm options (6 dimensions) ⚠️ unless --quick
|
||||
- [ ] Step 3: Create prompt
|
||||
- [ ] References in prompt ONLY if files exist in refs/
|
||||
- [ ] Extracted style/palette appended to prompt body (if no file)
|
||||
- [ ] Step 4: Generate image
|
||||
- [ ] 4.1 References verified before generation
|
||||
- [ ] 4.2 Pass refs via --ref if skill supports AND files exist
|
||||
- [ ] Step 5: Completion report
|
||||
```
|
||||
|
||||
@@ -189,240 +117,66 @@ Cover Image Progress:
|
||||
|
||||
```
|
||||
Input → [Step 0: Preferences] ─┬─ Found → Continue
|
||||
│
|
||||
└─ Not found → First-Time Setup ⛔ BLOCKING
|
||||
│
|
||||
└─ Complete setup → Save EXTEND.md → Continue
|
||||
│
|
||||
┌───────────────────────────────────────────────────────────────────────────┘
|
||||
└─ Not found → First-Time Setup ⛔ BLOCKING → Save EXTEND.md → Continue
|
||||
↓
|
||||
Analyze + Save Refs → [Output Dir ⚠️] → [Confirm: 5 Dimensions] → Prompt → Generate → Complete
|
||||
↓
|
||||
(skip if --quick or all specified)
|
||||
Analyze + Save Refs → [Output Dir] → [Confirm: 6 Dimensions] → Prompt → Generate → Complete
|
||||
↓
|
||||
(skip if --quick or all specified)
|
||||
```
|
||||
|
||||
### Step 0: Load Preferences (EXTEND.md) ⛔ BLOCKING
|
||||
|
||||
**Purpose**: Load user preferences or run first-time setup.
|
||||
|
||||
**CRITICAL**: If EXTEND.md not found, MUST complete first-time setup before ANY other questions or steps. Do NOT proceed to content analysis, do NOT ask about reference images, do NOT ask about dimensions — ONLY complete the preferences setup first.
|
||||
|
||||
Use Bash to check EXTEND.md existence (priority order):
|
||||
### Step 0: Load Preferences ⛔ BLOCKING
|
||||
|
||||
Check EXTEND.md existence (priority: project → user):
|
||||
```bash
|
||||
# Check project-level first
|
||||
test -f .baoyu-skills/baoyu-cover-image/EXTEND.md && echo "project"
|
||||
|
||||
# Then user-level (cross-platform: $HOME works on macOS/Linux/WSL)
|
||||
test -f "$HOME/.baoyu-skills/baoyu-cover-image/EXTEND.md" && echo "user"
|
||||
```
|
||||
|
||||
| Result | Action |
|
||||
|--------|--------|
|
||||
| Found | Read, parse, display preferences summary → Continue to Step 1 |
|
||||
| Not found | ⛔ **BLOCKING**: Run first-time setup ONLY ([references/config/first-time-setup.md](references/config/first-time-setup.md)) → Complete and save EXTEND.md → Then continue to Step 1 |
|
||||
| Found | Load, display summary → Continue |
|
||||
| Not found | ⛔ Run first-time setup ([references/config/first-time-setup.md](references/config/first-time-setup.md)) → Save → Continue |
|
||||
|
||||
**Preferences Summary** (when found):
|
||||
|
||||
```
|
||||
Preferences loaded from [project/user]:
|
||||
• Watermark: [enabled/disabled] [content if enabled]
|
||||
• Type/Palette/Rendering: [value or "auto"]
|
||||
• Text: [value or "title-only"] | Mood: [value or "balanced"]
|
||||
• Aspect: [default_aspect] | Output: [dir or "not set — will ask in Step 1.5"]
|
||||
• Quick mode: [enabled/disabled] | Language: [value or "auto"]
|
||||
```
|
||||
|
||||
**EXTEND.md Supports**: Watermark | Preferred type | Preferred palette | Preferred rendering | Preferred text | Preferred mood | Default aspect ratio | Default output directory | Quick mode | Custom palette definitions | Language preference
|
||||
|
||||
Schema: [references/config/preferences-schema.md](references/config/preferences-schema.md)
|
||||
**CRITICAL**: If not found, complete setup BEFORE any other steps or questions.
|
||||
|
||||
### Step 1: Analyze Content
|
||||
|
||||
**1.0 Detect & Save Reference Images** ⚠️ REQUIRED if images provided
|
||||
|
||||
Check if user provided reference images. Handle based on input type:
|
||||
|
||||
| Input Type | Action |
|
||||
|------------|--------|
|
||||
| Image file path provided | Copy to `refs/` subdirectory → can use `--ref` |
|
||||
| Image in conversation (no path) | **ASK user for file path** with AskUserQuestion |
|
||||
| User can't provide path | Extract style/palette verbally → append to prompt (NO frontmatter references) |
|
||||
|
||||
**CRITICAL**: Only add `references` to prompt frontmatter if files are ACTUALLY SAVED to `refs/` directory.
|
||||
|
||||
**If user provides file path**:
|
||||
1. Copy to `refs/ref-NN-{slug}.{ext}` (NN = 01, 02, ...)
|
||||
2. Create description: `refs/ref-NN-{slug}.md`
|
||||
3. Verify files exist before proceeding
|
||||
|
||||
**If user can't provide path** (extracted verbally):
|
||||
1. Analyze image visually, extract: colors, style, composition
|
||||
2. Create `refs/extracted-style.md` with extracted info
|
||||
3. DO NOT add `references` to prompt frontmatter
|
||||
4. Instead, append extracted style/colors directly to prompt text
|
||||
|
||||
**Description File Format** (only when file saved):
|
||||
```yaml
|
||||
---
|
||||
ref_id: NN
|
||||
filename: ref-NN-{slug}.{ext}
|
||||
usage: direct | style | palette
|
||||
---
|
||||
[User's description or auto-generated description]
|
||||
```
|
||||
|
||||
| Usage | When to Use |
|
||||
|-------|-------------|
|
||||
| `direct` | Reference matches desired output closely |
|
||||
| `style` | Extract visual style characteristics only |
|
||||
| `palette` | Extract color scheme only |
|
||||
|
||||
**Verification** (only for saved files):
|
||||
```
|
||||
Reference Images Saved:
|
||||
- ref-01-{slug}.png ✓ (can use --ref)
|
||||
- ref-02-{slug}.png ✓ (can use --ref)
|
||||
```
|
||||
|
||||
**Or for extracted style**:
|
||||
```
|
||||
Reference Style Extracted (no file):
|
||||
- Colors: #E8756D coral, #7ECFC0 mint...
|
||||
- Style: minimal flat vector, clean lines...
|
||||
→ Will append to prompt text (not --ref)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**1.1 Save Source Content**
|
||||
- If pasted, save to `source.md` in target directory; if file path, use as-is
|
||||
- **Backup rule**: If `source.md` exists, rename to `source-backup-YYYYMMDD-HHMMSS.md`
|
||||
|
||||
**1.2 Content Analysis**
|
||||
- Extract topic, core message, tone, keywords
|
||||
- Identify visual metaphors
|
||||
- Detect content type
|
||||
|
||||
**1.3 Reference Image Analysis** (if provided in Step 1.0)
|
||||
|
||||
For each reference image:
|
||||
|
||||
| Analysis | Description |
|
||||
|----------|-------------|
|
||||
| Visual characteristics | Style, colors, composition |
|
||||
| Content/subject | What the reference depicts |
|
||||
| Style match | Which type/palette/rendering align |
|
||||
| Usage recommendation | `direct` / `style` / `palette` |
|
||||
|
||||
**1.4 Language Detection**
|
||||
- Detect source language
|
||||
- Note user's input language
|
||||
- Compare with EXTEND.md preference
|
||||
|
||||
**1.5 Determine Output Directory**
|
||||
- Per File Structure rules
|
||||
- If no `default_output_dir` preference + file path input, include in Step 2 Q4
|
||||
1. **Save reference images** (if provided) → [references/workflow/reference-images.md](references/workflow/reference-images.md)
|
||||
2. **Save source content** (if pasted, save to `source.md`)
|
||||
3. **Analyze content**: topic, tone, keywords, visual metaphors
|
||||
4. **Deep analyze references** ⚠️: Extract specific, concrete elements (see reference-images.md)
|
||||
5. **Detect language**: Compare source, user input, EXTEND.md preference
|
||||
6. **Determine output directory**: Per File Structure rules
|
||||
|
||||
### Step 2: Confirm Options ⚠️
|
||||
|
||||
Validate all 5 dimensions + aspect ratio. Full confirmation flow: [references/workflow/confirm-options.md](references/workflow/confirm-options.md)
|
||||
|
||||
**Skip Conditions**:
|
||||
Full confirmation flow: [references/workflow/confirm-options.md](references/workflow/confirm-options.md)
|
||||
|
||||
| Condition | Skipped | Still Asked |
|
||||
|-----------|---------|-------------|
|
||||
| `--quick` or `quick_mode: true` | 5 dimensions | Aspect ratio (unless `--aspect`) |
|
||||
| All 5 + `--aspect` specified | All | None |
|
||||
| `--quick` or `quick_mode: true` | 6 dimensions | Aspect ratio (unless `--aspect`) |
|
||||
| All 6 + `--aspect` specified | All | None |
|
||||
|
||||
### Step 3: Create Prompt
|
||||
|
||||
**Backup rule**: If `prompts/cover.md` exists, rename to `prompts/cover-backup-YYYYMMDD-HHMMSS.md`
|
||||
Save to `prompts/cover.md`. Template: [references/workflow/prompt-template.md](references/workflow/prompt-template.md)
|
||||
|
||||
Save to `prompts/cover.md`. Full template: [references/workflow/prompt-template.md](references/workflow/prompt-template.md)
|
||||
**CRITICAL - References in Frontmatter**:
|
||||
- Files saved to `refs/` → Add to frontmatter `references` list
|
||||
- Style extracted verbally (no file) → Omit `references`, describe in body
|
||||
- Before writing → Verify: `test -f refs/ref-NN-{slug}.{ext}`
|
||||
|
||||
**CRITICAL - References in YAML Frontmatter**:
|
||||
|
||||
When reference files are saved to `refs/`, **MUST add `references` field in frontmatter**:
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: cover
|
||||
palette: warm
|
||||
rendering: flat-vector
|
||||
references:
|
||||
- ref_id: 01
|
||||
filename: refs/ref-01-podcast-thumbnail.jpg
|
||||
usage: style
|
||||
---
|
||||
```
|
||||
|
||||
| Rule | Action |
|
||||
|------|--------|
|
||||
| Files saved to `refs/` | Add to frontmatter `references` list |
|
||||
| Style extracted verbally (no file) | Omit `references` field, describe in body |
|
||||
| Before writing | Verify: `test -f refs/ref-NN-{slug}.{ext}` |
|
||||
|
||||
**Reference Embedding**:
|
||||
|
||||
| Situation | Frontmatter | Body |
|
||||
|-----------|-------------|------|
|
||||
| Reference file saved to `refs/` | Add to `references` ✓ | Brief style note |
|
||||
| Style extracted verbally (no file) | Omit `references` | Full style description |
|
||||
| File in frontmatter but doesn't exist | ERROR - fix or remove | — |
|
||||
**Reference elements in body** MUST be detailed, prefixed with "MUST"/"REQUIRED", with integration approach.
|
||||
|
||||
### Step 4: Generate Image
|
||||
|
||||
**4.1 Backup existing** `cover.png` → `cover-backup-YYYYMMDD-HHMMSS.png` (if regenerating)
|
||||
|
||||
**4.2 Check available image generation skills**; if multiple, ask user preference
|
||||
|
||||
**4.3 Process References** ⚠️ REQUIRED if references in frontmatter
|
||||
|
||||
**Read `references` from prompt frontmatter** and process each entry:
|
||||
|
||||
1. **Parse frontmatter** to get references list:
|
||||
```yaml
|
||||
references:
|
||||
- ref_id: 01
|
||||
filename: refs/ref-01-podcast-thumbnail.jpg
|
||||
usage: style
|
||||
```
|
||||
|
||||
2. **VERIFY each file exists**:
|
||||
```bash
|
||||
test -f refs/ref-NN-{slug}.{ext} && echo "exists" || echo "MISSING"
|
||||
```
|
||||
- If file MISSING → ERROR, fix prompt or remove from references
|
||||
- If file exists → proceed with processing
|
||||
|
||||
3. Process based on `usage` type:
|
||||
|
||||
| Usage | Action | Example |
|
||||
|-------|--------|---------|
|
||||
| `direct` | Add reference path to `--ref` parameter | `--ref refs/ref-01-brand.png` |
|
||||
| `style` | Analyze reference, append style traits to prompt | "Style: clean lines, gradient backgrounds..." |
|
||||
| `palette` | Extract colors from reference, append to prompt | "Colors: #E8756D coral, #7ECFC0 mint..." |
|
||||
|
||||
3. Check image generation skill capability:
|
||||
|
||||
| Skill Supports `--ref` | Action |
|
||||
|------------------------|--------|
|
||||
| Yes (e.g., baoyu-image-gen with Google) | Pass reference images via `--ref` |
|
||||
| No | Convert to text description, append to prompt |
|
||||
|
||||
**Verification**: Before generating, confirm reference processing:
|
||||
```
|
||||
Reference Processing:
|
||||
- ref-01-brand.png: using as direct reference ✓
|
||||
- ref-02-style.png: extracted palette ✓
|
||||
```
|
||||
|
||||
**4.4 Generate**
|
||||
|
||||
1. Call selected skill with prompt file path, output path (`cover.png`), aspect ratio
|
||||
2. If references with `direct` usage AND skill supports `--ref`: include `--ref` parameter
|
||||
3. On failure: auto-retry once before reporting error
|
||||
1. **Backup existing** `cover.png` if regenerating
|
||||
2. **Check image generation skills**; if multiple, ask preference
|
||||
3. **Process references** from prompt frontmatter:
|
||||
- `direct` usage → pass via `--ref` (use ref-capable backend)
|
||||
- `style`/`palette` → extract traits, append to prompt
|
||||
4. **Generate**: Call skill with prompt file, output path, aspect ratio
|
||||
5. On failure: auto-retry once
|
||||
|
||||
### Step 5: Completion Report
|
||||
|
||||
@@ -431,67 +185,49 @@ Cover Generated!
|
||||
|
||||
Topic: [topic]
|
||||
Type: [type] | Palette: [palette] | Rendering: [rendering]
|
||||
Text: [text] | Mood: [mood] | Aspect: [ratio]
|
||||
Title: [title text or "visual only"]
|
||||
Text: [text] | Mood: [mood] | Font: [font] | Aspect: [ratio]
|
||||
Title: [title or "visual only"]
|
||||
Language: [lang] | Watermark: [enabled/disabled]
|
||||
References: [N images (direct/style/palette) or "extracted style" or "none"]
|
||||
References: [N images or "extracted style" or "none"]
|
||||
Location: [directory path]
|
||||
|
||||
Files:
|
||||
✓ source-{slug}.{ext}
|
||||
[✓ refs/ref-01-{slug}.{ext} ... (if references saved)]
|
||||
[✓ refs/ref-01-{slug}.md ... (description files)]
|
||||
[✓ refs/extracted-style.md (if style extracted verbally)]
|
||||
✓ prompts/cover.md
|
||||
✓ cover.png
|
||||
[✓ cover-backup-{timestamp}.png (if regenerated)]
|
||||
```
|
||||
|
||||
## Image Modification
|
||||
|
||||
| Action | Steps |
|
||||
|--------|-------|
|
||||
| **Regenerate** | Backup existing → **Update prompt file FIRST** → Regenerate with same settings |
|
||||
| **Change dimension** | Backup existing → Confirm new value → **Update prompt file FIRST** → Regenerate |
|
||||
| **Regenerate** | Backup → Update prompt file FIRST → Regenerate |
|
||||
| **Change dimension** | Backup → Confirm new value → Update prompt → Regenerate |
|
||||
|
||||
**IMPORTANT**: When regenerating, ALWAYS update the prompt file (`prompts/cover.md`) FIRST before regenerating. This ensures changes are documented and reproducible.
|
||||
## Composition Principles
|
||||
|
||||
All modifications automatically backup existing `cover.png` before regenerating.
|
||||
- **Whitespace**: 40-60% breathing room
|
||||
- **Visual anchor**: Main element centered or offset left
|
||||
- **Characters**: Simplified silhouettes; NO realistic humans
|
||||
- **Title**: Use exact title from user/source; never invent
|
||||
|
||||
## Notes
|
||||
## Extension Support
|
||||
|
||||
- Cover must be readable at small preview sizes
|
||||
- Visual metaphors > literal representations
|
||||
- Title: readable, impactful
|
||||
- Two confirmation points: Step 0 (first-time setup) + Step 2 (options) - skip Step 2 with `--quick`
|
||||
- Use confirmed language for title text
|
||||
- Maintain watermark consistency if enabled
|
||||
- Check compatibility matrices when selecting combinations
|
||||
- `--no-title` is alias for `--text none`
|
||||
- `--style` presets are backward-compatible; explicit `--palette`/`--rendering` override preset values
|
||||
Custom configurations via EXTEND.md. See **Step 0** for paths.
|
||||
|
||||
### Composition Principles
|
||||
Supports: Watermark | Preferred dimensions | Default aspect/output | Quick mode | Custom palettes | Language
|
||||
|
||||
- **Generous whitespace**: 40-60% breathing room; avoid cluttered layouts
|
||||
- **Visual anchor**: Main element centered or offset left (reserve right for title)
|
||||
- **Character handling**: Simplified silhouettes or icon-style figures; NO realistic humans
|
||||
- **Icon vocabulary**: Use simple, recognizable symbols (see [references/visual-elements.md](references/visual-elements.md))
|
||||
|
||||
### Title Handling
|
||||
|
||||
- **Source**: Use the exact title provided by user, or extract from source content
|
||||
- **Do NOT invent titles**: Stay faithful to the original
|
||||
- If no title in source and user doesn't provide one, ask user to specify
|
||||
Schema: [references/config/preferences-schema.md](references/config/preferences-schema.md)
|
||||
|
||||
## References
|
||||
|
||||
**Dimensions**: [text.md](references/dimensions/text.md) | [mood.md](references/dimensions/mood.md)
|
||||
**Dimensions**: [text.md](references/dimensions/text.md) | [mood.md](references/dimensions/mood.md) | [font.md](references/dimensions/font.md)
|
||||
**Palettes**: [references/palettes/](references/palettes/)
|
||||
**Renderings**: [references/renderings/](references/renderings/)
|
||||
**Types**: [references/types.md](references/types.md)
|
||||
**Auto-Selection**: [references/auto-selection.md](references/auto-selection.md)
|
||||
**Style Presets**: [references/style-presets.md](references/style-presets.md)
|
||||
**Compatibility**: [references/compatibility.md](references/compatibility.md)
|
||||
**Types**: [references/types.md](references/types.md)
|
||||
**Visual Elements**: [references/visual-elements.md](references/visual-elements.md)
|
||||
**Workflow**: [confirm-options.md](references/workflow/confirm-options.md) | [prompt-template.md](references/workflow/prompt-template.md)
|
||||
**Workflow**: [confirm-options.md](references/workflow/confirm-options.md) | [prompt-template.md](references/workflow/prompt-template.md) | [reference-images.md](references/workflow/reference-images.md)
|
||||
**Config**: [preferences-schema.md](references/config/preferences-schema.md) | [first-time-setup.md](references/config/first-time-setup.md) | [watermark-guide.md](references/config/watermark-guide.md)
|
||||
|
||||
@@ -58,3 +58,14 @@ Default: `title-only`
|
||||
| Launch, announcement, promotion, event, gaming, entertainment | `bold` |
|
||||
|
||||
Default: `balanced`
|
||||
|
||||
## Auto Font Selection
|
||||
|
||||
| Signals | Font |
|
||||
|---------|------|
|
||||
| Personal, lifestyle, human, warm, friendly, story | `handwritten` |
|
||||
| Technical, professional, clean, modern, minimal, data | `clean` |
|
||||
| Editorial, academic, luxury, classic, literary | `serif` |
|
||||
| Announcement, entertainment, promotion, bold, event, gaming | `display` |
|
||||
|
||||
Default: `clean`
|
||||
|
||||
@@ -48,3 +48,12 @@
|
||||
| metaphor | ✓✓ | ✓✓ | ✓ |
|
||||
| scene | ✓✓ | ✓✓ | ✓ |
|
||||
| minimal | ✓✓ | ✓✓ | ✗ |
|
||||
|
||||
## Font × Rendering
|
||||
|
||||
| | flat-vector | hand-drawn | painterly | digital | pixel | chalk |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| clean | ✓✓ | ✗ | ✗ | ✓✓ | ✓ | ✗ |
|
||||
| handwritten | ✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✓✓ |
|
||||
| serif | ✓ | ✗ | ✓ | ✓✓ | ✗ | ✗ |
|
||||
| display | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
name: font-dimension
|
||||
description: Typography style dimension for cover images
|
||||
---
|
||||
|
||||
# Font Dimension
|
||||
|
||||
Controls typography style and character feel.
|
||||
|
||||
## Values
|
||||
|
||||
| Font | Visual Style | Line Quality | Character |
|
||||
|------|--------------|--------------|-----------|
|
||||
| `clean` | Geometric sans-serif | Sharp, uniform | Modern, precise, neutral |
|
||||
| `handwritten` | Hand-lettered, brush | Organic, varied | Warm, personal, friendly |
|
||||
| `serif` | Classic serifs, elegant | Refined, structured | Editorial, authoritative |
|
||||
| `display` | Bold, decorative | Heavy, expressive | Attention-grabbing, playful |
|
||||
|
||||
## Detail
|
||||
|
||||
### clean
|
||||
|
||||
Modern, universal typography with neutral character.
|
||||
|
||||
**Characteristics**:
|
||||
- Geometric sans-serif letterforms
|
||||
- Sharp, uniform line weight
|
||||
- Clean edges, no flourishes
|
||||
- High readability at all sizes
|
||||
- Minimal personality, maximum clarity
|
||||
|
||||
**Use Cases**:
|
||||
- Technical documentation
|
||||
- Professional/corporate content
|
||||
- Minimal design approaches
|
||||
- Data-driven articles
|
||||
- Modern brand aesthetics
|
||||
|
||||
**Prompt Hints**:
|
||||
- Use clean geometric sans-serif typography
|
||||
- Modern, minimal letterforms
|
||||
- Sharp edges, uniform stroke weight
|
||||
- High contrast against background
|
||||
|
||||
### handwritten
|
||||
|
||||
Warm, organic typography with personal character.
|
||||
|
||||
**Characteristics**:
|
||||
- Hand-lettered or brush style
|
||||
- Organic, varied line weight
|
||||
- Natural imperfections
|
||||
- Approachable, human feel
|
||||
- Casual yet intentional
|
||||
|
||||
**Use Cases**:
|
||||
- Personal stories
|
||||
- Lifestyle content
|
||||
- Wellness and self-improvement
|
||||
- Creative tutorials
|
||||
- Friendly brand voices
|
||||
|
||||
**Prompt Hints**:
|
||||
- Use warm hand-lettered typography with organic brush strokes
|
||||
- Friendly, personal feel
|
||||
- Natural variation in stroke weight
|
||||
- Approachable, human character
|
||||
|
||||
### serif
|
||||
|
||||
Classic, elegant typography with editorial authority.
|
||||
|
||||
**Characteristics**:
|
||||
- Traditional serif letterforms
|
||||
- Refined, structured strokes
|
||||
- Elegant proportions
|
||||
- Timeless sophistication
|
||||
- Formal, trustworthy feel
|
||||
|
||||
**Use Cases**:
|
||||
- Editorial content
|
||||
- Academic articles
|
||||
- Luxury brand content
|
||||
- Historical topics
|
||||
- Literary pieces
|
||||
|
||||
**Prompt Hints**:
|
||||
- Use elegant serif typography with refined letterforms
|
||||
- Classic, editorial character
|
||||
- Structured, proportional spacing
|
||||
- Authoritative, sophisticated feel
|
||||
|
||||
### display
|
||||
|
||||
Bold, decorative typography for maximum impact.
|
||||
|
||||
**Characteristics**:
|
||||
- Heavy, expressive letterforms
|
||||
- Decorative elements
|
||||
- Strong visual presence
|
||||
- Playful or dramatic character
|
||||
- Designed for headlines
|
||||
|
||||
**Use Cases**:
|
||||
- Announcements
|
||||
- Entertainment content
|
||||
- Promotional materials
|
||||
- Event marketing
|
||||
- Gaming topics
|
||||
|
||||
**Prompt Hints**:
|
||||
- Use bold decorative display typography
|
||||
- Heavy, expressive headlines
|
||||
- Strong visual impact
|
||||
- Attention-grabbing character
|
||||
|
||||
## Default
|
||||
|
||||
`clean` — Universal, pairs well with most rendering styles.
|
||||
|
||||
## Rendering Compatibility
|
||||
|
||||
| Font × Rendering | flat-vector | hand-drawn | painterly | digital | pixel | chalk |
|
||||
|------------------|:-----------:|:----------:|:---------:|:-------:|:-----:|:-----:|
|
||||
| clean | ✓✓ | ✗ | ✗ | ✓✓ | ✓ | ✗ |
|
||||
| handwritten | ✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✓✓ |
|
||||
| serif | ✓ | ✗ | ✓ | ✓✓ | ✗ | ✗ |
|
||||
| display | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
|
||||
✓✓ = highly recommended | ✓ = compatible | ✗ = not recommended
|
||||
|
||||
## Type Compatibility
|
||||
|
||||
| Font × Type | hero | conceptual | typography | metaphor | scene | minimal |
|
||||
|-------------|:----:|:----------:|:----------:|:--------:|:-----:|:-------:|
|
||||
| clean | ✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✓✓ |
|
||||
| handwritten | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
| serif | ✓ | ✓ | ✓✓ | ✓ | ✓ | ✓ |
|
||||
| display | ✓✓ | ✓ | ✓✓ | ✓ | ✓ | ✗ |
|
||||
|
||||
## Palette Interaction
|
||||
|
||||
Font style adapts to palette characteristics:
|
||||
|
||||
| Palette Category | clean | handwritten | serif | display |
|
||||
|------------------|-------|-------------|-------|---------|
|
||||
| Warm (warm, earth, pastel) | Softer weight | Natural fit | Warm tones | Playful energy |
|
||||
| Cool (cool, mono, elegant) | Perfect match | Contrast | Classic pairing | Bold statement |
|
||||
| Dark (dark, vivid) | High contrast | Glow effects | Dramatic | Maximum impact |
|
||||
| Vintage (retro) | Modern contrast | Nostalgic fit | Period-appropriate | Retro headlines |
|
||||
|
||||
## Auto Selection
|
||||
|
||||
When `--font` is omitted, select based on signals:
|
||||
|
||||
| Signals | Font |
|
||||
|---------|------|
|
||||
| Personal, lifestyle, human, warm, friendly, story | `handwritten` |
|
||||
| Technical, professional, clean, modern, minimal, data | `clean` |
|
||||
| Editorial, academic, luxury, classic, literary | `serif` |
|
||||
| Announcement, entertainment, promotion, bold, event, gaming | `display` |
|
||||
|
||||
Default: `clean`
|
||||
@@ -22,6 +22,7 @@
|
||||
| `vector-illustration` | `retro` | `flat-vector` |
|
||||
| `vintage` | `retro` | `hand-drawn` |
|
||||
| `warm` | `warm` | `hand-drawn` |
|
||||
| `warm-flat` | `warm` | `flat-vector` |
|
||||
| `watercolor` | `earth` | `painterly` |
|
||||
|
||||
## Override Examples
|
||||
|
||||
@@ -2,22 +2,22 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
Validate all 5 dimensions + aspect ratio.
|
||||
Validate all 6 dimensions + aspect ratio.
|
||||
|
||||
## Skip Conditions
|
||||
|
||||
| Condition | Skipped Questions | Still Asked |
|
||||
|-----------|-------------------|-------------|
|
||||
| `--quick` flag | Type, Palette, Rendering, Text, Mood | **Aspect Ratio** (unless `--aspect` specified) |
|
||||
| All 5 dimensions + `--aspect` specified | All | None |
|
||||
| `quick_mode: true` in EXTEND.md | Type, Palette, Rendering, Text, Mood | **Aspect Ratio** (unless `--aspect` specified) |
|
||||
| Otherwise | None | All 6 questions |
|
||||
| `--quick` flag | Type, Palette, Rendering, Text, Mood, Font | **Aspect Ratio** (unless `--aspect` specified) |
|
||||
| All 6 dimensions + `--aspect` specified | All | None |
|
||||
| `quick_mode: true` in EXTEND.md | Type, Palette, Rendering, Text, Mood, Font | **Aspect Ratio** (unless `--aspect` specified) |
|
||||
| Otherwise | None | All 7 questions |
|
||||
|
||||
**Important**: Aspect ratio is ALWAYS asked unless explicitly specified via `--aspect` CLI flag. User presets in EXTEND.md are shown as recommended option, not auto-selected.
|
||||
|
||||
## Quick Mode Output
|
||||
|
||||
When skipping 5 dimensions:
|
||||
When skipping 6 dimensions:
|
||||
|
||||
```
|
||||
Quick Mode: Auto-selected dimensions
|
||||
@@ -26,8 +26,9 @@ Quick Mode: Auto-selected dimensions
|
||||
• Rendering: [rendering] ([reason])
|
||||
• Text: [text] ([reason])
|
||||
• Mood: [mood] ([reason])
|
||||
• Font: [font] ([reason])
|
||||
|
||||
[Then ask Question 6: Aspect Ratio]
|
||||
[Then ask Question 7: Aspect Ratio]
|
||||
```
|
||||
|
||||
## Confirmation Flow
|
||||
@@ -91,7 +92,26 @@ options:
|
||||
description: "Polished, precise, subtle gradients"
|
||||
```
|
||||
|
||||
### Q4: Other Settings (skip if all remaining dimensions already specified)
|
||||
### Q4: Font (skip if `--font`)
|
||||
|
||||
```yaml
|
||||
header: "Font"
|
||||
question: "Which font style?"
|
||||
multiSelect: false
|
||||
options:
|
||||
- label: "[auto-recommended font] (Recommended)"
|
||||
description: "[reason based on content signals]"
|
||||
- label: "clean"
|
||||
description: "Modern geometric sans-serif - tech, professional"
|
||||
- label: "handwritten"
|
||||
description: "Warm hand-lettered - personal, friendly"
|
||||
- label: "serif"
|
||||
description: "Classic elegant - editorial, luxury"
|
||||
- label: "display"
|
||||
description: "Bold decorative - announcements, entertainment"
|
||||
```
|
||||
|
||||
### Q5: Other Settings (skip if all remaining dimensions already specified)
|
||||
|
||||
Combine remaining settings into one question. Include: Output Dir (if no preference + file path input), Text, Mood, Aspect. Show auto-selected values as recommended option. User can accept all or type adjustments via "Other".
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ Cover theme: [2-3 words visual interpretation]
|
||||
Type: [confirmed type]
|
||||
Palette: [confirmed palette]
|
||||
Rendering: [confirmed rendering]
|
||||
Font: [confirmed font]
|
||||
Text level: [confirmed text level]
|
||||
Mood: [confirmed mood]
|
||||
Aspect ratio: [confirmed ratio]
|
||||
@@ -44,6 +45,13 @@ Language: [confirmed language]
|
||||
- balanced: "Use medium contrast, normal saturation, balanced visual weight"
|
||||
- bold: "Use high contrast, vivid saturated colors, heavy visual weight, dynamic energy"
|
||||
|
||||
# Font Application
|
||||
[Based on font style:]
|
||||
- clean: "Use clean geometric sans-serif typography. Modern, minimal letterforms."
|
||||
- handwritten: "Use warm hand-lettered typography with organic brush strokes. Friendly, personal feel."
|
||||
- serif: "Use elegant serif typography with refined letterforms. Classic, editorial character."
|
||||
- display: "Use bold decorative display typography. Heavy, expressive headlines."
|
||||
|
||||
# Composition
|
||||
Type composition:
|
||||
- [Type-specific layout and structure]
|
||||
@@ -60,9 +68,15 @@ Palette notes: [key characteristics from palette definition]
|
||||
|
||||
[Watermark section if enabled]
|
||||
|
||||
[Reference images section if provided — see below]
|
||||
[Reference images section if provided — REQUIRED, see below]
|
||||
```
|
||||
|
||||
## Reference-Driven Design ⚠️ HIGH PRIORITY
|
||||
|
||||
When reference images are provided, they are the **primary visual input** and MUST strongly influence the output. The cover should look like it belongs to the same visual family as the references.
|
||||
|
||||
**Passing `--ref` alone is NOT enough.** Image generation models often ignore reference images unless the prompt text explicitly describes what to reproduce. Always combine `--ref` with detailed textual instructions.
|
||||
|
||||
## Content-Driven Design
|
||||
|
||||
- Article title and summary inform the visual metaphor choice
|
||||
@@ -167,63 +181,67 @@ For each reference image, extract:
|
||||
- **Color mood**: Palette characteristics (without specific colors)
|
||||
- **Elements**: Key visual elements and symbols used
|
||||
|
||||
### Step 2: Embed in Prompt
|
||||
### Step 2: Embed in Prompt ⚠️ CRITICAL
|
||||
|
||||
**If file saved AND skill supports `--ref`** (e.g., baoyu-image-gen with Google):
|
||||
- Pass ref images directly via `--ref` parameter
|
||||
- Add brief style note in prompt:
|
||||
**Passing `--ref` alone is NOT enough.** Image generation models frequently ignore reference images unless the prompt text explicitly and forcefully describes what to reproduce. You MUST always write detailed textual instructions regardless of whether `--ref` is used.
|
||||
|
||||
**If file saved (with or without `--ref` support)**:
|
||||
- Pass ref images via `--ref` parameter if skill supports it
|
||||
- **ALWAYS** add a detailed mandatory section in the prompt body:
|
||||
|
||||
```
|
||||
# Reference Style
|
||||
Follow the visual style of the attached reference image(s):
|
||||
- [1-2 sentence style summary from analysis]
|
||||
# Reference Style — MUST INCORPORATE
|
||||
|
||||
CRITICAL: The generated cover MUST visually reference the provided images. The cover must feel like it belongs to the same visual family.
|
||||
|
||||
## From Ref 1 ([filename]) — REQUIRED elements:
|
||||
- [Brand element]: [Specific description of logo/wordmark treatment, e.g., "The logo uses vertical parallel lines (|||) for the letter 'm'. Reproduce this exact treatment."]
|
||||
- [Signature pattern]: [Specific description, e.g., "Woven intersecting curves forming a diamond/lozenge grid pattern. This MUST appear prominently as a banner, border, or background section."]
|
||||
- [Colors]: [Exact hex values, e.g., "Dark teal #2D4A3E background, cream #F5F0E0 text"]
|
||||
- [Typography]: [Specific treatment, e.g., "Uppercase text with wide letter-spacing"]
|
||||
- [Layout element]: [Specific spatial element, e.g., "Bottom banner strip in dark color"]
|
||||
|
||||
## From Ref 2 ([filename]) — REQUIRED elements:
|
||||
[Same detailed breakdown]
|
||||
|
||||
## Integration approach:
|
||||
[Specific layout instruction describing how reference elements combine with the cover content, e.g., "Use a SPLIT LAYOUT: main illustration area (warm cream background) occupies ~65% of the image, while a dark teal BANNER STRIP (with the woven line pattern from Ref 2) runs along the bottom ~35%, containing branding elements from Ref 1."]
|
||||
```
|
||||
|
||||
**If file saved BUT skill does NOT support `--ref`**:
|
||||
- Embed detailed text description in prompt:
|
||||
|
||||
```
|
||||
# Reference Style (Text Description)
|
||||
Emulate the following visual style from reference image(s):
|
||||
|
||||
Reference 1: [filename]
|
||||
- Style: [detailed rendering technique description]
|
||||
- Composition: [layout and hierarchy description]
|
||||
- Elements: [key visual elements used]
|
||||
- Mood: [emotional tone and visual weight]
|
||||
|
||||
[Repeat for additional references]
|
||||
|
||||
Apply these style characteristics to the cover design while maintaining the specified Type, Palette, and Rendering dimensions.
|
||||
```
|
||||
**Key rules**:
|
||||
- Each visual element gets its own bullet with "MUST" or "REQUIRED"
|
||||
- Descriptions must be **specific enough to reproduce** — not vague ("clean style")
|
||||
- The integration approach must describe **exact spatial arrangement**
|
||||
- After generation, verify reference elements are visible; if not, strengthen and regenerate
|
||||
|
||||
**If style/palette extracted verbally (NO file saved)**:
|
||||
- DO NOT add references metadata to prompt
|
||||
- Append extracted info directly to prompt body:
|
||||
- Append extracted info directly to prompt body using the same MUST INCORPORATE format above:
|
||||
|
||||
```
|
||||
# Extracted Style (from reference)
|
||||
# Reference Style — MUST INCORPORATE (extracted from visual analysis)
|
||||
|
||||
COLORS (from reference):
|
||||
- Primary: #E8756D coral
|
||||
- Secondary: #7ECFC0 mint
|
||||
...
|
||||
CRITICAL: Apply these specific visual elements extracted from the reference images.
|
||||
|
||||
STYLE (from reference):
|
||||
- Clean lines, minimal shadows
|
||||
- Gradient backgrounds
|
||||
- Flat vector icons
|
||||
...
|
||||
## REQUIRED elements:
|
||||
- [Same detailed bullet format as above]
|
||||
|
||||
## Integration approach:
|
||||
[Same spatial layout instruction]
|
||||
```
|
||||
|
||||
### Reference Analysis Template
|
||||
|
||||
Use this format when analyzing reference images:
|
||||
Use this format when analyzing reference images. Extract **specific, concrete, reproducible** details — not vague summaries.
|
||||
|
||||
| Aspect | Analysis Points |
|
||||
|--------|-----------------|
|
||||
| **Rendering** | Line quality (clean/sketchy/painted), texture presence, depth treatment |
|
||||
| **Composition** | Element placement, whitespace ratio, visual flow |
|
||||
| **Typography** | Font style, text placement, hierarchy (if present) |
|
||||
| **Elements** | Icon vocabulary, decorative motifs, character style |
|
||||
| **Mood** | Contrast level, saturation, visual weight |
|
||||
| Aspect | Analysis Points | Good Example | Bad Example |
|
||||
|--------|-----------------|--------------|-------------|
|
||||
| **Brand elements** | Logos, wordmarks, distinctive typography | "Logo 'm' formed by 3 vertical lines" | "Has a logo" |
|
||||
| **Signature patterns** | Unique motifs, textures, geometric patterns | "Woven curves forming diamond grid" | "Has patterns" |
|
||||
| **Colors** | Exact hex values or close approximations | "#2D4A3E dark teal, #F5F0E0 cream" | "Dark and light" |
|
||||
| **Layout** | Spatial zones, banner placement, proportions | "Bottom 30% is dark banner with branding" | "Has a banner" |
|
||||
| **Typography** | Font style, weight, case, spacing, position | "Uppercase, wide letter-spacing, right-aligned" | "Has text" |
|
||||
| **Rendering** | Line quality, texture, depth treatment | "Topographic contour lines as background texture" | "Clean style" |
|
||||
| **Elements** | Icon vocabulary, decorative motifs | "Geometric intersecting line ornaments at corners" | "Has decorations" |
|
||||
|
||||
**Output**: Each extracted element should be written as a **copy-pasteable prompt instruction** prefixed with "MUST" or "REQUIRED".
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Reference Image Handling
|
||||
|
||||
Guide for processing user-provided reference images in cover generation.
|
||||
|
||||
## Input Detection
|
||||
|
||||
| Input Type | Action |
|
||||
|------------|--------|
|
||||
| Image file path provided | Copy to `refs/` → can use `--ref` |
|
||||
| Image in conversation (no path) | **ASK user for file path** with AskUserQuestion |
|
||||
| User can't provide path | Extract style/palette verbally → append to prompt (NO frontmatter references) |
|
||||
|
||||
**CRITICAL**: Only add `references` to prompt frontmatter if files are ACTUALLY SAVED to `refs/` directory.
|
||||
|
||||
## File Saving
|
||||
|
||||
**If user provides file path**:
|
||||
1. Copy to `refs/ref-NN-{slug}.{ext}` (NN = 01, 02, ...)
|
||||
2. Create description: `refs/ref-NN-{slug}.md`
|
||||
3. Verify files exist before proceeding
|
||||
|
||||
**Description File Format**:
|
||||
```yaml
|
||||
---
|
||||
ref_id: NN
|
||||
filename: ref-NN-{slug}.{ext}
|
||||
usage: direct | style | palette
|
||||
---
|
||||
[User's description or auto-generated description]
|
||||
```
|
||||
|
||||
| Usage | When to Use |
|
||||
|-------|-------------|
|
||||
| `direct` | Reference matches desired output closely |
|
||||
| `style` | Extract visual style characteristics only |
|
||||
| `palette` | Extract color scheme only |
|
||||
|
||||
## Verbal Extraction (No File)
|
||||
|
||||
When user can't provide file path:
|
||||
1. Analyze image visually, extract: colors, style, composition
|
||||
2. Create `refs/extracted-style.md` with extracted info
|
||||
3. DO NOT add `references` to prompt frontmatter
|
||||
4. Append extracted style/colors directly to prompt text
|
||||
|
||||
## Deep Analysis ⚠️ CRITICAL
|
||||
|
||||
References are high-priority inputs. Extract **specific, concrete, reproducible** elements:
|
||||
|
||||
| Analysis | Description | Example (good vs bad) |
|
||||
|----------|-------------|----------------------|
|
||||
| **Brand elements** | Logos, wordmarks, specific typography | Good: "Logo uses vertical parallel lines for 'm'" / Bad: "Has a logo" |
|
||||
| **Signature patterns** | Unique decorative motifs, textures | Good: "Woven intersecting curves forming diamond grid" / Bad: "Has patterns" |
|
||||
| **Color palette** | Exact hex values for key colors | Good: "#2D4A3E dark teal, #F5F0E0 cream" / Bad: "Dark and light colors" |
|
||||
| **Layout structure** | Specific spatial arrangement | Good: "Bottom 30% dark banner with branding" / Bad: "Has a banner" |
|
||||
| **Typography** | Font style, weight, spacing, case | Good: "Uppercase, wide letter-spacing" / Bad: "Has text" |
|
||||
| **Content/subject** | What the reference depicts | Factual description |
|
||||
| **Usage recommendation** | `direct` / `style` / `palette` | Based on analysis |
|
||||
|
||||
**Output format**: List each element as bullet that can be copy-pasted into prompt as mandatory instruction.
|
||||
|
||||
## Verification Output
|
||||
|
||||
**For saved files**:
|
||||
```
|
||||
Reference Images Saved:
|
||||
- ref-01-{slug}.png ✓ (can use --ref)
|
||||
- ref-02-{slug}.png ✓ (can use --ref)
|
||||
```
|
||||
|
||||
**For extracted style**:
|
||||
```
|
||||
Reference Style Extracted (no file):
|
||||
- Colors: #E8756D coral, #7ECFC0 mint...
|
||||
- Style: minimal flat vector, clean lines...
|
||||
→ Will append to prompt text (not --ref)
|
||||
```
|
||||
|
||||
## Priority Rules
|
||||
|
||||
When user provides references, they are **HIGH PRIORITY**:
|
||||
|
||||
- **References override defaults**: If reference conflicts with preferred palette/rendering, reference takes precedence
|
||||
- **Concrete > abstract**: Extract specific elements — not vague "clean style"
|
||||
- **Mandatory language**: Use "MUST", "REQUIRED" in prompt for reference elements
|
||||
- **Visible in output**: Verify elements are present after generation; strengthen prompt if not
|
||||
@@ -92,16 +92,52 @@ test -f "$HOME/.baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md" && echo "user
|
||||
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||
│ Found │ Read, parse, apply settings │
|
||||
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||
│ Not found │ Use defaults │
|
||||
│ Not found │ **MUST** run first-time setup (see below) — do NOT silently create defaults │
|
||||
└───────────┴───────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
**EXTEND.md Supports**: Default output directory | Output format preferences
|
||||
**EXTEND.md Supports**: Download media by default | Default output directory
|
||||
|
||||
### First-Time Setup (BLOCKING)
|
||||
|
||||
**CRITICAL**: When EXTEND.md is not found, you **MUST use `AskUserQuestion`** to ask the user for their preferences before creating EXTEND.md. **NEVER** create EXTEND.md with defaults without asking. This is a **BLOCKING** operation — do NOT proceed with any conversion until setup is complete.
|
||||
|
||||
Use `AskUserQuestion` with ALL questions in ONE call:
|
||||
|
||||
**Question 1** — header: "Media", question: "How to handle images and videos in tweets?"
|
||||
- "Ask each time (Recommended)" — After saving markdown, ask whether to download media
|
||||
- "Always download" — Always download media to local imgs/ and videos/ directories
|
||||
- "Never download" — Keep original remote URLs in markdown
|
||||
|
||||
**Question 2** — header: "Output", question: "Default output directory?"
|
||||
- "x-to-markdown (Recommended)" — Save to ./x-to-markdown/{username}/{tweet-id}.md
|
||||
- (User may choose "Other" to type a custom path)
|
||||
|
||||
**Question 3** — header: "Save", question: "Where to save preferences?"
|
||||
- "User (Recommended)" — ~/.baoyu-skills/ (all projects)
|
||||
- "Project" — .baoyu-skills/ (this project only)
|
||||
|
||||
After user answers, create EXTEND.md at the chosen location, confirm "Preferences saved to [path]", then continue.
|
||||
|
||||
Full reference: [references/config/first-time-setup.md](references/config/first-time-setup.md)
|
||||
|
||||
### Supported Keys
|
||||
|
||||
| Key | Default | Values | Description |
|
||||
|-----|---------|--------|-------------|
|
||||
| `download_media` | `ask` | `ask` / `1` / `0` | `ask` = prompt each time, `1` = always download, `0` = never |
|
||||
| `default_output_dir` | empty | path or empty | Default output directory (empty = `./x-to-markdown/`) |
|
||||
|
||||
**Value priority**:
|
||||
1. CLI arguments (`--download-media`, `-o`)
|
||||
2. EXTEND.md
|
||||
3. Skill defaults
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts <url>
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts <url> -o output.md
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --download-media
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --json
|
||||
```
|
||||
|
||||
@@ -112,6 +148,7 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --json
|
||||
| `<url>` | Tweet or article URL |
|
||||
| `-o <path>` | Output path |
|
||||
| `--json` | JSON output |
|
||||
| `--download-media` | Download image/video assets to local `imgs/` and `videos/`, and rewrite markdown links to local relative paths |
|
||||
| `--login` | Refresh cookies only |
|
||||
|
||||
## Supported URLs
|
||||
@@ -124,9 +161,10 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --json
|
||||
|
||||
```markdown
|
||||
---
|
||||
url: https://x.com/user/status/123
|
||||
url: "https://x.com/user/status/123"
|
||||
author: "Name (@user)"
|
||||
tweet_count: 3
|
||||
tweetCount: 3
|
||||
coverImage: "https://pbs.twimg.com/media/example.jpg"
|
||||
---
|
||||
|
||||
Content...
|
||||
@@ -134,6 +172,32 @@ Content...
|
||||
|
||||
**File structure**: `x-to-markdown/{username}/{tweet-id}.md`
|
||||
|
||||
When `--download-media` is enabled:
|
||||
- Images are saved to `imgs/` next to the markdown file
|
||||
- Videos are saved to `videos/` next to the markdown file
|
||||
- Markdown media links are rewritten to local relative paths
|
||||
|
||||
## Media Download Workflow
|
||||
|
||||
Based on `download_media` setting in EXTEND.md:
|
||||
|
||||
| Setting | Behavior |
|
||||
|---------|----------|
|
||||
| `1` (always) | Run script with `--download-media` flag |
|
||||
| `0` (never) | Run script without `--download-media` flag |
|
||||
| `ask` (default) | Follow the ask-each-time flow below |
|
||||
|
||||
### Ask-Each-Time Flow
|
||||
|
||||
1. Run script **without** `--download-media` → markdown saved
|
||||
2. Check saved markdown for remote media URLs (`https://` in image/video links)
|
||||
3. **If no remote media found** → done, no prompt needed
|
||||
4. **If remote media found** → use `AskUserQuestion`:
|
||||
- header: "Media", question: "Download N images/videos to local files?"
|
||||
- "Yes" — Download to local directories
|
||||
- "No" — Keep remote URLs
|
||||
5. If user confirms → run script **again** with `--download-media` (overwrites markdown with localized links)
|
||||
|
||||
## Authentication
|
||||
|
||||
1. **Environment variables** (preferred): `X_AUTH_TOKEN`, `X_CT0`
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
name: first-time-setup
|
||||
description: First-time setup flow for baoyu-danger-x-to-markdown preferences
|
||||
---
|
||||
|
||||
# First-Time Setup
|
||||
|
||||
## Overview
|
||||
|
||||
When no EXTEND.md is found, guide user through preference setup.
|
||||
|
||||
**BLOCKING OPERATION**: This setup MUST complete before ANY other workflow steps. Do NOT:
|
||||
- Start converting tweets or articles
|
||||
- Ask about URLs or output paths
|
||||
- Proceed to any conversion
|
||||
|
||||
ONLY ask the questions in this setup flow, save EXTEND.md, then continue.
|
||||
|
||||
## Setup Flow
|
||||
|
||||
```
|
||||
No EXTEND.md found
|
||||
|
|
||||
v
|
||||
+---------------------+
|
||||
| AskUserQuestion |
|
||||
| (all questions) |
|
||||
+---------------------+
|
||||
|
|
||||
v
|
||||
+---------------------+
|
||||
| Create EXTEND.md |
|
||||
+---------------------+
|
||||
|
|
||||
v
|
||||
Continue conversion
|
||||
```
|
||||
|
||||
## Questions
|
||||
|
||||
**Language**: Use user's input language or saved language preference.
|
||||
|
||||
Use AskUserQuestion with ALL questions in ONE call:
|
||||
|
||||
### Question 1: Download Media
|
||||
|
||||
```yaml
|
||||
header: "Media"
|
||||
question: "How to handle images and videos in tweets?"
|
||||
options:
|
||||
- label: "Ask each time (Recommended)"
|
||||
description: "After saving markdown, ask whether to download media"
|
||||
- label: "Always download"
|
||||
description: "Always download media to local imgs/ and videos/ directories"
|
||||
- label: "Never download"
|
||||
description: "Keep original remote URLs in markdown"
|
||||
```
|
||||
|
||||
### Question 2: Default Output Directory
|
||||
|
||||
```yaml
|
||||
header: "Output"
|
||||
question: "Default output directory?"
|
||||
options:
|
||||
- label: "x-to-markdown (Recommended)"
|
||||
description: "Save to ./x-to-markdown/{username}/{tweet-id}.md"
|
||||
```
|
||||
|
||||
Note: User will likely choose "Other" to type a custom path.
|
||||
|
||||
### Question 3: Save Location
|
||||
|
||||
```yaml
|
||||
header: "Save"
|
||||
question: "Where to save preferences?"
|
||||
options:
|
||||
- label: "User (Recommended)"
|
||||
description: "~/.baoyu-skills/ (all projects)"
|
||||
- label: "Project"
|
||||
description: ".baoyu-skills/ (this project only)"
|
||||
```
|
||||
|
||||
## Save Locations
|
||||
|
||||
| Choice | Path | Scope |
|
||||
|--------|------|-------|
|
||||
| User | `~/.baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md` | All projects |
|
||||
| Project | `.baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md` | Current project |
|
||||
|
||||
## After Setup
|
||||
|
||||
1. Create directory if needed
|
||||
2. Write EXTEND.md
|
||||
3. Confirm: "Preferences saved to [path]"
|
||||
4. Continue with conversion using saved preferences
|
||||
|
||||
## EXTEND.md Template
|
||||
|
||||
```md
|
||||
download_media: [ask/1/0]
|
||||
default_output_dir: [path or empty]
|
||||
```
|
||||
|
||||
## Modifying Preferences Later
|
||||
|
||||
Users can edit EXTEND.md directly or delete it to trigger setup again.
|
||||
@@ -6,6 +6,7 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
|
||||
import { fetchXArticle } from "./graphql.js";
|
||||
import { formatArticleMarkdown } from "./markdown.js";
|
||||
import { localizeMarkdownMedia, type LocalizeMarkdownMediaResult } from "./media-localizer.js";
|
||||
import { hasRequiredXCookies, loadXCookies, refreshXCookies } from "./cookies.js";
|
||||
import { resolveXToMarkdownConsentPath } from "./paths.js";
|
||||
import { tweetToMarkdown } from "./tweet-to-markdown.js";
|
||||
@@ -15,6 +16,7 @@ type CliArgs = {
|
||||
output: string | null;
|
||||
json: boolean;
|
||||
login: boolean;
|
||||
downloadMedia: boolean;
|
||||
help: boolean;
|
||||
};
|
||||
|
||||
@@ -38,6 +40,7 @@ Usage:
|
||||
Options:
|
||||
--output <path>, -o Output path (file or dir). Default: ./x-to-markdown/<slug>/
|
||||
--json Output as JSON
|
||||
--download-media Download images/videos to local ./imgs and ./videos next to markdown
|
||||
--login Refresh cookies only, then exit
|
||||
--help, -h Show help
|
||||
|
||||
@@ -45,6 +48,7 @@ Examples:
|
||||
${cmd} https://x.com/username/status/1234567890
|
||||
${cmd} https://x.com/i/article/1234567890 -o ./article.md
|
||||
${cmd} https://x.com/username/status/1234567890 -o ./out/
|
||||
${cmd} https://x.com/username/status/1234567890 --download-media
|
||||
${cmd} https://x.com/username/status/1234567890 --json | jq -r '.markdownPath'
|
||||
${cmd} --login
|
||||
`);
|
||||
@@ -57,6 +61,7 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
output: null,
|
||||
json: false,
|
||||
login: false,
|
||||
downloadMedia: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
@@ -80,6 +85,11 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === "--download-media") {
|
||||
out.downloadMedia = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === "--url") {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error("Missing value for --url");
|
||||
@@ -345,16 +355,17 @@ async function convertArticleToMarkdown(
|
||||
|
||||
log(`[x-to-markdown] Fetching article ${articleId}...`);
|
||||
const article = await fetchXArticle(articleId, cookieMap, false);
|
||||
const body = formatArticleMarkdown(article).trimEnd();
|
||||
const { markdown: body, coverUrl } = formatArticleMarkdown(article);
|
||||
|
||||
const title = typeof (article as any)?.title === "string" ? String((article as any).title).trim() : "";
|
||||
const meta = formatMetaMarkdown({
|
||||
url: `https://x.com/i/article/${articleId}`,
|
||||
requested_url: inputUrl,
|
||||
requestedUrl: inputUrl,
|
||||
title: title || null,
|
||||
coverImage: coverUrl,
|
||||
});
|
||||
|
||||
return [meta, body].filter(Boolean).join("\n\n").trimEnd();
|
||||
return [meta, body.trimEnd()].filter(Boolean).join("\n\n").trimEnd();
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -385,11 +396,24 @@ async function main(): Promise<void> {
|
||||
const kind = articleId ? ("article" as const) : ("tweet" as const);
|
||||
const { outputDir, markdownPath, slug } = await resolveOutputPath(normalizedUrl, kind, args.output, log);
|
||||
|
||||
const markdown =
|
||||
let markdown =
|
||||
kind === "article" && articleId
|
||||
? await convertArticleToMarkdown(normalizedUrl, articleId, log)
|
||||
: await tweetToMarkdown(normalizedUrl, { log });
|
||||
|
||||
let mediaResult: LocalizeMarkdownMediaResult | null = null;
|
||||
|
||||
if (args.downloadMedia) {
|
||||
mediaResult = await localizeMarkdownMedia(markdown, {
|
||||
markdownPath,
|
||||
log,
|
||||
});
|
||||
markdown = mediaResult.markdown;
|
||||
log(
|
||||
`[x-to-markdown] Media localized: images=${mediaResult.downloadedImages}, videos=${mediaResult.downloadedVideos}`
|
||||
);
|
||||
}
|
||||
|
||||
await writeFile(markdownPath, markdown, "utf8");
|
||||
log(`[x-to-markdown] Saved: ${markdownPath}`);
|
||||
|
||||
@@ -398,11 +422,16 @@ async function main(): Promise<void> {
|
||||
JSON.stringify(
|
||||
{
|
||||
url: articleId ? `https://x.com/i/article/${articleId}` : normalizedUrl,
|
||||
requested_url: normalizedUrl,
|
||||
requestedUrl: normalizedUrl,
|
||||
type: kind,
|
||||
slug,
|
||||
outputDir,
|
||||
markdownPath,
|
||||
downloadMedia: args.downloadMedia,
|
||||
downloadedImages: mediaResult?.downloadedImages ?? 0,
|
||||
downloadedVideos: mediaResult?.downloadedVideos ?? 0,
|
||||
imageDir: mediaResult?.imageDir ?? null,
|
||||
videoDir: mediaResult?.videoDir ?? null,
|
||||
},
|
||||
null,
|
||||
2
|
||||
|
||||
@@ -257,10 +257,15 @@ function renderContentBlocks(
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function formatArticleMarkdown(article: unknown): string {
|
||||
export type FormatArticleResult = {
|
||||
markdown: string;
|
||||
coverUrl: string | null;
|
||||
};
|
||||
|
||||
export function formatArticleMarkdown(article: unknown): FormatArticleResult {
|
||||
const candidate = coerceArticleEntity(article);
|
||||
if (!candidate) {
|
||||
return `\`\`\`json\n${JSON.stringify(article, null, 2)}\n\`\`\``;
|
||||
return { markdown: `\`\`\`json\n${JSON.stringify(article, null, 2)}\n\`\`\``, coverUrl: null };
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
@@ -271,10 +276,8 @@ export function formatArticleMarkdown(article: unknown): string {
|
||||
lines.push(`# ${title}`);
|
||||
}
|
||||
|
||||
const coverUrl = resolveMediaUrl(candidate.cover_media?.media_info);
|
||||
const coverUrl = resolveMediaUrl(candidate.cover_media?.media_info) ?? null;
|
||||
if (coverUrl) {
|
||||
if (lines.length > 0) lines.push("");
|
||||
lines.push(``);
|
||||
usedUrls.add(coverUrl);
|
||||
}
|
||||
|
||||
@@ -294,7 +297,7 @@ export function formatArticleMarkdown(article: unknown): string {
|
||||
lines.push(candidate.preview_text.trim());
|
||||
}
|
||||
|
||||
const mediaUrls = collectMediaUrls(candidate, usedUrls, coverUrl);
|
||||
const mediaUrls = collectMediaUrls(candidate, usedUrls, coverUrl ?? undefined);
|
||||
if (mediaUrls.length > 0) {
|
||||
lines.push("", "## Media", "");
|
||||
for (const url of mediaUrls) {
|
||||
@@ -302,5 +305,5 @@ export function formatArticleMarkdown(article: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n").trimEnd();
|
||||
return { markdown: lines.join("\n").trimEnd(), coverUrl };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import path from "node:path";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
|
||||
type MediaKind = "image" | "video";
|
||||
type MediaHint = "image" | "unknown";
|
||||
|
||||
type MarkdownLinkCandidate = {
|
||||
url: string;
|
||||
hint: MediaHint;
|
||||
};
|
||||
|
||||
export type LocalizeMarkdownMediaOptions = {
|
||||
markdownPath: string;
|
||||
log?: (message: string) => void;
|
||||
};
|
||||
|
||||
export type LocalizeMarkdownMediaResult = {
|
||||
markdown: string;
|
||||
downloadedImages: number;
|
||||
downloadedVideos: number;
|
||||
imageDir: string | null;
|
||||
videoDir: string | null;
|
||||
};
|
||||
|
||||
const MARKDOWN_LINK_RE = /(!?\[[^\]\n]*\])\((<)?(https?:\/\/[^)\s>]+)(>)?\)/g;
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"webp",
|
||||
"gif",
|
||||
"bmp",
|
||||
"avif",
|
||||
"heic",
|
||||
"heif",
|
||||
"svg",
|
||||
]);
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set(["mp4", "m4v", "mov", "webm", "mkv"]);
|
||||
|
||||
const MIME_EXTENSION_MAP: Record<string, string> = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/jpg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
"image/gif": "gif",
|
||||
"image/bmp": "bmp",
|
||||
"image/avif": "avif",
|
||||
"image/heic": "heic",
|
||||
"image/heif": "heif",
|
||||
"image/svg+xml": "svg",
|
||||
"video/mp4": "mp4",
|
||||
"video/webm": "webm",
|
||||
"video/quicktime": "mov",
|
||||
"video/x-m4v": "m4v",
|
||||
};
|
||||
|
||||
const DOWNLOAD_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";
|
||||
|
||||
function normalizeContentType(raw: string | null): string {
|
||||
return raw?.split(";")[0]?.trim().toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function normalizeExtension(raw: string | undefined | null): string | undefined {
|
||||
if (!raw) return undefined;
|
||||
const trimmed = raw.replace(/^\./, "").trim().toLowerCase();
|
||||
if (!trimmed) return undefined;
|
||||
if (trimmed === "jpeg") return "jpg";
|
||||
if (trimmed === "jpg") return "jpg";
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function resolveExtensionFromUrl(rawUrl: string): string | undefined {
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
const extFromPath = normalizeExtension(path.posix.extname(parsed.pathname));
|
||||
if (extFromPath) return extFromPath;
|
||||
const extFromFormat = normalizeExtension(parsed.searchParams.get("format"));
|
||||
if (extFromFormat) return extFromFormat;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveKindFromContentType(contentType: string): MediaKind | undefined {
|
||||
if (!contentType) return undefined;
|
||||
if (contentType.startsWith("image/")) return "image";
|
||||
if (contentType.startsWith("video/")) return "video";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveKindFromExtension(ext: string | undefined): MediaKind | undefined {
|
||||
if (!ext) return undefined;
|
||||
if (IMAGE_EXTENSIONS.has(ext)) return "image";
|
||||
if (VIDEO_EXTENSIONS.has(ext)) return "video";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveKindFromHostname(rawUrl: string): MediaKind | undefined {
|
||||
try {
|
||||
const hostname = new URL(rawUrl).hostname.toLowerCase();
|
||||
if (hostname.includes("video.twimg.com")) return "video";
|
||||
if (hostname.includes("pbs.twimg.com")) return "image";
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveMediaKind(
|
||||
rawUrl: string,
|
||||
contentType: string,
|
||||
extension: string | undefined,
|
||||
hint: MediaHint
|
||||
): MediaKind | undefined {
|
||||
const kindFromType = resolveKindFromContentType(contentType);
|
||||
if (kindFromType) return kindFromType;
|
||||
|
||||
const kindFromExtension = resolveKindFromExtension(extension);
|
||||
if (kindFromExtension) return kindFromExtension;
|
||||
|
||||
const kindFromHost = resolveKindFromHostname(rawUrl);
|
||||
if (kindFromHost) return kindFromHost;
|
||||
|
||||
if (contentType && contentType !== "application/octet-stream") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return hint === "image" ? "image" : undefined;
|
||||
}
|
||||
|
||||
function resolveOutputExtension(
|
||||
contentType: string,
|
||||
extension: string | undefined,
|
||||
kind: MediaKind
|
||||
): string {
|
||||
const extFromMime = normalizeExtension(MIME_EXTENSION_MAP[contentType]);
|
||||
if (extFromMime) return extFromMime;
|
||||
|
||||
const normalizedExt = normalizeExtension(extension);
|
||||
if (normalizedExt) return normalizedExt;
|
||||
|
||||
return kind === "video" ? "mp4" : "jpg";
|
||||
}
|
||||
|
||||
function safeDecodeURIComponent(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeFileSegment(input: string): string {
|
||||
return input
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^[-_]+|[-_]+$/g, "")
|
||||
.slice(0, 48);
|
||||
}
|
||||
|
||||
function resolveFileStem(rawUrl: string, extension: string): string {
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
const base = path.posix.basename(parsed.pathname);
|
||||
if (!base) return "";
|
||||
const decodedBase = safeDecodeURIComponent(base);
|
||||
const normalizedExt = normalizeExtension(extension);
|
||||
const stripExt = normalizedExt ? new RegExp(`\\.${normalizedExt}$`, "i") : null;
|
||||
const rawStem = stripExt ? decodedBase.replace(stripExt, "") : decodedBase;
|
||||
return sanitizeFileSegment(rawStem);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function buildFileName(kind: MediaKind, index: number, sourceUrl: string, extension: string): string {
|
||||
const stem = resolveFileStem(sourceUrl, extension);
|
||||
const prefix = kind === "image" ? "img" : "video";
|
||||
const serial = String(index).padStart(3, "0");
|
||||
const suffix = stem ? `-${stem}` : "";
|
||||
return `${prefix}-${serial}${suffix}.${extension}`;
|
||||
}
|
||||
|
||||
const FRONTMATTER_COVER_RE = /^(coverImage:\s*")(https?:\/\/[^"]+)(")/m;
|
||||
|
||||
function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[] {
|
||||
MARKDOWN_LINK_RE.lastIndex = 0;
|
||||
const candidates: MarkdownLinkCandidate[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = MARKDOWN_LINK_RE.exec(markdown))) {
|
||||
const label = match[1] ?? "";
|
||||
const rawUrl = match[3] ?? "";
|
||||
if (!rawUrl || seen.has(rawUrl)) continue;
|
||||
seen.add(rawUrl);
|
||||
candidates.push({
|
||||
url: rawUrl,
|
||||
hint: label.startsWith("![") ? "image" : "unknown",
|
||||
});
|
||||
}
|
||||
|
||||
const fmMatch = markdown.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (fmMatch) {
|
||||
const coverMatch = fmMatch[1]?.match(FRONTMATTER_COVER_RE);
|
||||
if (coverMatch?.[2] && !seen.has(coverMatch[2])) {
|
||||
seen.add(coverMatch[2]);
|
||||
candidates.push({ url: coverMatch[2], hint: "image" });
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function rewriteMarkdownMediaLinks(markdown: string, replacements: Map<string, string>): string {
|
||||
if (replacements.size === 0) return markdown;
|
||||
MARKDOWN_LINK_RE.lastIndex = 0;
|
||||
|
||||
let result = markdown.replace(MARKDOWN_LINK_RE, (full, label, _openAngle, rawUrl) => {
|
||||
const localPath = replacements.get(rawUrl);
|
||||
if (!localPath) return full;
|
||||
return `${label}(${localPath})`;
|
||||
});
|
||||
|
||||
result = result.replace(FRONTMATTER_COVER_RE, (full, prefix, rawUrl, suffix) => {
|
||||
const localPath = replacements.get(rawUrl);
|
||||
if (!localPath) return full;
|
||||
return `${prefix}${localPath}${suffix}`;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function localizeMarkdownMedia(
|
||||
markdown: string,
|
||||
options: LocalizeMarkdownMediaOptions
|
||||
): Promise<LocalizeMarkdownMediaResult> {
|
||||
const log = options.log ?? (() => {});
|
||||
const markdownDir = path.dirname(options.markdownPath);
|
||||
const candidates = collectMarkdownLinkCandidates(markdown);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
markdown,
|
||||
downloadedImages: 0,
|
||||
downloadedVideos: 0,
|
||||
imageDir: null,
|
||||
videoDir: null,
|
||||
};
|
||||
}
|
||||
|
||||
const replacements = new Map<string, string>();
|
||||
let downloadedImages = 0;
|
||||
let downloadedVideos = 0;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const response = await fetch(candidate.url, {
|
||||
method: "GET",
|
||||
redirect: "follow",
|
||||
headers: {
|
||||
"user-agent": DOWNLOAD_USER_AGENT,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
log(`[x-to-markdown] Skip media (${response.status}): ${candidate.url}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceUrl = response.url || candidate.url;
|
||||
const contentType = normalizeContentType(response.headers.get("content-type"));
|
||||
const extension = resolveExtensionFromUrl(sourceUrl) ?? resolveExtensionFromUrl(candidate.url);
|
||||
const kind = resolveMediaKind(sourceUrl, contentType, extension, candidate.hint);
|
||||
if (!kind) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const outputExtension = resolveOutputExtension(contentType, extension, kind);
|
||||
const nextIndex = kind === "image" ? downloadedImages + 1 : downloadedVideos + 1;
|
||||
const dirName = kind === "image" ? "imgs" : "videos";
|
||||
const targetDir = path.join(markdownDir, dirName);
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
|
||||
const fileName = buildFileName(kind, nextIndex, sourceUrl, outputExtension);
|
||||
const absolutePath = path.join(targetDir, fileName);
|
||||
const relativePath = path.posix.join(dirName, fileName);
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
await writeFile(absolutePath, bytes);
|
||||
replacements.set(candidate.url, relativePath);
|
||||
|
||||
if (kind === "image") {
|
||||
downloadedImages = nextIndex;
|
||||
} else {
|
||||
downloadedVideos = nextIndex;
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error ?? "");
|
||||
log(`[x-to-markdown] Failed to download media ${candidate.url}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
markdown: rewriteMarkdownMediaLinks(markdown, replacements),
|
||||
downloadedImages,
|
||||
downloadedVideos,
|
||||
imageDir: downloadedImages > 0 ? path.join(markdownDir, "imgs") : null,
|
||||
videoDir: downloadedVideos > 0 ? path.join(markdownDir, "videos") : null,
|
||||
};
|
||||
}
|
||||
@@ -123,22 +123,15 @@ export async function tweetToMarkdown(
|
||||
const requestedUrl = normalizedUrl || buildTweetUrl(username, tweetId) || inputUrl.trim();
|
||||
const rootUrl = buildTweetUrl(username, thread.rootId ?? tweetId) ?? requestedUrl;
|
||||
|
||||
const meta = formatMetaMarkdown({
|
||||
url: rootUrl,
|
||||
requested_url: requestedUrl,
|
||||
author,
|
||||
author_name: name ?? null,
|
||||
author_username: username ?? null,
|
||||
author_url: authorUrl ?? null,
|
||||
tweet_count: thread.totalTweets ?? tweets.length,
|
||||
});
|
||||
|
||||
const parts: string[] = [meta];
|
||||
|
||||
const articleEntity = await resolveArticleEntityFromTweet(firstTweet, cookieMap);
|
||||
let coverImage: string | null = null;
|
||||
let remainingTweets = tweets;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (articleEntity) {
|
||||
const articleMarkdown = formatArticleMarkdown(articleEntity).trimEnd();
|
||||
const articleResult = formatArticleMarkdown(articleEntity);
|
||||
coverImage = articleResult.coverUrl;
|
||||
const articleMarkdown = articleResult.markdown.trimEnd();
|
||||
if (articleMarkdown) {
|
||||
parts.push(articleMarkdown);
|
||||
const firstTweetText = extractTweetText(firstTweet);
|
||||
@@ -148,6 +141,19 @@ export async function tweetToMarkdown(
|
||||
}
|
||||
}
|
||||
|
||||
const meta = formatMetaMarkdown({
|
||||
url: rootUrl,
|
||||
requestedUrl: requestedUrl,
|
||||
author,
|
||||
authorName: name ?? null,
|
||||
authorUsername: username ?? null,
|
||||
authorUrl: authorUrl ?? null,
|
||||
tweetCount: thread.totalTweets ?? tweets.length,
|
||||
coverImage,
|
||||
});
|
||||
|
||||
parts.unshift(meta);
|
||||
|
||||
if (remainingTweets.length > 0) {
|
||||
const hasArticle = parts.length > 1;
|
||||
if (hasArticle) {
|
||||
|
||||
@@ -127,7 +127,7 @@ Check for YAML frontmatter (`---` block). Create if missing.
|
||||
| `title` | See Step 4 |
|
||||
| `slug` | Infer from file path (e.g., `posts/2026/01/10/vibe-coding/` → `vibe-coding`) or generate from title |
|
||||
| `summary` | Generate engaging summary (100-150 characters) |
|
||||
| `featureImage` | Check if `imgs/cover.png` exists in same directory; if so, use relative path |
|
||||
| `coverImage` | Check if `imgs/cover.png` exists in same directory; if so, use relative path (also accepted: `featureImage`) |
|
||||
|
||||
### Step 4: Title Handling
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: baoyu-image-gen
|
||||
description: AI image generation with OpenAI, Google and DashScope APIs. Supports text-to-image, reference images, aspect ratios. Sequential by default; parallel generation available on request. Use when user asks to generate, create, or draw images.
|
||||
description: AI image generation with OpenAI, Google, DashScope and Replicate APIs. Supports text-to-image, reference images, aspect ratios. Sequential by default; parallel generation available on request. Use when user asks to generate, create, or draw images.
|
||||
---
|
||||
|
||||
# Image Generation (AI SDK)
|
||||
|
||||
Official API-based image generation. Supports OpenAI, Google and DashScope (阿里通义万象) providers.
|
||||
Official API-based image generation. Supports OpenAI, Google, DashScope (阿里通义万象) and Replicate providers.
|
||||
|
||||
## Script Directory
|
||||
|
||||
@@ -41,7 +41,9 @@ test -f "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "user"
|
||||
│ Not found │ Use defaults │
|
||||
└───────────┴───────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio
|
||||
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio | Default image size | Default models
|
||||
|
||||
Schema: `references/config/preferences-schema.md`
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -58,14 +60,23 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --quali
|
||||
# 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)
|
||||
# With reference images (Google multimodal or OpenAI edits)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Make blue" --image out.png --ref source.png
|
||||
|
||||
# With reference images (explicit provider/model)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Make blue" --image out.png --provider google --model gemini-3-pro-image-preview --ref source.png
|
||||
|
||||
# Specific provider
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider openai
|
||||
|
||||
# DashScope (阿里通义万象)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "一只可爱的猫" --image out.png --provider dashscope
|
||||
|
||||
# Replicate (google/nano-banana-pro)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
|
||||
|
||||
# Replicate with specific model
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate --model google/nano-banana
|
||||
```
|
||||
|
||||
## Options
|
||||
@@ -75,13 +86,13 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "一只可爱的猫" --image ou
|
||||
| `--prompt <text>`, `-p` | Prompt text |
|
||||
| `--promptfiles <files...>` | Read prompt from files (concatenated) |
|
||||
| `--image <path>` | Output image path (required) |
|
||||
| `--provider google\|openai\|dashscope` | Force provider (default: google) |
|
||||
| `--model <id>`, `-m` | Model ID |
|
||||
| `--provider google\|openai\|dashscope\|replicate` | Force provider (default: google) |
|
||||
| `--model <id>`, `-m` | Model ID (`--ref` with OpenAI requires GPT Image model, e.g. `gpt-image-1.5`) |
|
||||
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
||||
| `--size <WxH>` | Size (e.g., `1024x1024`) |
|
||||
| `--quality normal\|2k` | Quality preset (default: 2k) |
|
||||
| `--imageSize 1K\|2K\|4K` | Image size for Google (default: from quality) |
|
||||
| `--ref <files...>` | Reference images (Google multimodal only) |
|
||||
| `--ref <files...>` | Reference images. Supported by Google multimodal and OpenAI edits (GPT Image models). If provider omitted: Google first, then OpenAI |
|
||||
| `--n <count>` | Number of images |
|
||||
| `--json` | JSON output |
|
||||
|
||||
@@ -92,20 +103,48 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "一只可爱的猫" --image ou
|
||||
| `OPENAI_API_KEY` | OpenAI API key |
|
||||
| `GOOGLE_API_KEY` | Google API key |
|
||||
| `DASHSCOPE_API_KEY` | DashScope API key (阿里云) |
|
||||
| `REPLICATE_API_TOKEN` | Replicate API token |
|
||||
| `OPENAI_IMAGE_MODEL` | OpenAI model override |
|
||||
| `GOOGLE_IMAGE_MODEL` | Google model override |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope model override (default: z-image-turbo) |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate model override (default: google/nano-banana-pro) |
|
||||
| `OPENAI_BASE_URL` | Custom OpenAI endpoint |
|
||||
| `GOOGLE_BASE_URL` | Custom Google endpoint |
|
||||
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint |
|
||||
| `REPLICATE_BASE_URL` | Custom Replicate endpoint |
|
||||
|
||||
**Load Priority**: CLI args > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
|
||||
**Load Priority**: CLI args > EXTEND.md > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
|
||||
|
||||
## Replicate Model Configuration
|
||||
|
||||
When using `--provider replicate`, the model can be configured in the following ways (highest priority first):
|
||||
|
||||
1. CLI flag: `--model <owner/name>`
|
||||
2. EXTEND.md: `default_model.replicate`
|
||||
3. Env var: `REPLICATE_IMAGE_MODEL`
|
||||
4. Built-in default: `google/nano-banana-pro`
|
||||
|
||||
Supported model formats:
|
||||
|
||||
- `owner/name` (recommended for official models), e.g. `google/nano-banana-pro`
|
||||
- `owner/name:version` (community models by version), e.g. `stability-ai/sdxl:<version>`
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Use Replicate default model
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
|
||||
|
||||
# Override model explicitly
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate --model google/nano-banana
|
||||
```
|
||||
|
||||
## Provider Selection
|
||||
|
||||
1. `--provider` specified → use it
|
||||
2. Only one API key available → use that provider
|
||||
3. Multiple available → default to Google
|
||||
1. `--ref` provided + no `--provider` → auto-select Google first, then OpenAI, then Replicate
|
||||
2. `--provider` specified → use it (if `--ref`, must be `google`, `openai`, or `replicate`)
|
||||
3. Only one API key available → use that provider
|
||||
4. Multiple available → default to Google
|
||||
|
||||
## Quality Presets
|
||||
|
||||
@@ -155,7 +194,7 @@ Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`
|
||||
- Missing API key → error with setup instructions
|
||||
- Generation failure → auto-retry once
|
||||
- Invalid aspect ratio → warning, proceed with default
|
||||
- Reference images with non-multimodal model → warning, ignore refs
|
||||
- Reference images with unsupported provider/model → error with fix hint (switch to Google multimodal or OpenAI GPT Image edits)
|
||||
|
||||
## Extension Support
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: preferences-schema
|
||||
description: EXTEND.md YAML schema for baoyu-image-gen user preferences
|
||||
---
|
||||
|
||||
# Preferences Schema
|
||||
|
||||
## Full Schema
|
||||
|
||||
```yaml
|
||||
---
|
||||
version: 1
|
||||
|
||||
default_provider: null # google|openai|dashscope|replicate|null (null = auto-detect)
|
||||
|
||||
default_quality: null # normal|2k|null (null = use default: 2k)
|
||||
|
||||
default_aspect_ratio: null # "16:9"|"1:1"|"4:3"|"3:4"|"2.35:1"|null
|
||||
|
||||
default_image_size: null # 1K|2K|4K|null (Google only, overrides quality)
|
||||
|
||||
default_model:
|
||||
google: null # e.g., "gemini-3-pro-image-preview"
|
||||
openai: null # e.g., "gpt-image-1.5"
|
||||
dashscope: null # e.g., "z-image-turbo"
|
||||
replicate: null # e.g., "google/nano-banana-pro"
|
||||
---
|
||||
```
|
||||
|
||||
## Field Reference
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `version` | int | 1 | Schema version |
|
||||
| `default_provider` | string\|null | null | Default provider (null = auto-detect) |
|
||||
| `default_quality` | string\|null | null | Default quality (null = 2k) |
|
||||
| `default_aspect_ratio` | string\|null | null | Default aspect ratio |
|
||||
| `default_image_size` | string\|null | null | Google image size (overrides quality) |
|
||||
| `default_model.google` | string\|null | null | Google default model |
|
||||
| `default_model.openai` | string\|null | null | OpenAI default model |
|
||||
| `default_model.dashscope` | string\|null | null | DashScope default model |
|
||||
| `default_model.replicate` | string\|null | null | Replicate default model |
|
||||
|
||||
## Examples
|
||||
|
||||
**Minimal**:
|
||||
```yaml
|
||||
---
|
||||
version: 1
|
||||
default_provider: google
|
||||
default_quality: 2k
|
||||
---
|
||||
```
|
||||
|
||||
**Full**:
|
||||
```yaml
|
||||
---
|
||||
version: 1
|
||||
default_provider: google
|
||||
default_quality: 2k
|
||||
default_aspect_ratio: "16:9"
|
||||
default_image_size: 2K
|
||||
default_model:
|
||||
google: "gemini-3-pro-image-preview"
|
||||
openai: "gpt-image-1.5"
|
||||
dashscope: "z-image-turbo"
|
||||
replicate: "google/nano-banana-pro"
|
||||
---
|
||||
```
|
||||
@@ -1,8 +1,8 @@
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { homedir } from "node:os";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import type { CliArgs, Provider } from "./types";
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import type { CliArgs, Provider, ExtendConfig } from "./types";
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(`Usage:
|
||||
@@ -14,13 +14,13 @@ Options:
|
||||
-p, --prompt <text> Prompt text
|
||||
--promptfiles <files...> Read prompt from files (concatenated)
|
||||
--image <path> Output image path (required)
|
||||
--provider google|openai|dashscope Force provider (auto-detect by default)
|
||||
--provider google|openai|dashscope|replicate 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: 2k)
|
||||
--imageSize 1K|2K|4K Image size for Google (default: from quality)
|
||||
--ref <files...> Reference images (Google multimodal only)
|
||||
--ref <files...> Reference images (Google multimodal or OpenAI edits)
|
||||
--n <count> Number of images (default: 1)
|
||||
--json JSON output
|
||||
-h, --help Show help
|
||||
@@ -30,14 +30,17 @@ Environment variables:
|
||||
GOOGLE_API_KEY Google API key
|
||||
GEMINI_API_KEY Gemini API key (alias for GOOGLE_API_KEY)
|
||||
DASHSCOPE_API_KEY DashScope API key (阿里云通义万象)
|
||||
REPLICATE_API_TOKEN Replicate API token
|
||||
OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-1.5)
|
||||
GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image-preview)
|
||||
DASHSCOPE_IMAGE_MODEL Default DashScope model (z-image-turbo)
|
||||
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-pro)
|
||||
OPENAI_BASE_URL Custom OpenAI endpoint
|
||||
GOOGLE_BASE_URL Custom Google endpoint
|
||||
DASHSCOPE_BASE_URL Custom DashScope endpoint
|
||||
REPLICATE_BASE_URL Custom Replicate endpoint
|
||||
|
||||
Env file load order: CLI args > process.env > <cwd>/.baoyu-skills/.env > ~/.baoyu-skills/.env`);
|
||||
Env file load order: CLI args > EXTEND.md > process.env > <cwd>/.baoyu-skills/.env > ~/.baoyu-skills/.env`);
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
@@ -49,7 +52,7 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
size: null,
|
||||
quality: "2k",
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
@@ -108,7 +111,7 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
|
||||
if (a === "--provider") {
|
||||
const v = argv[++i];
|
||||
if (v !== "google" && v !== "openai" && v !== "dashscope") throw new Error(`Invalid provider: ${v}`);
|
||||
if (v !== "google" && v !== "openai" && v !== "dashscope" && v !== "replicate") throw new Error(`Invalid provider: ${v}`);
|
||||
out.provider = v;
|
||||
continue;
|
||||
}
|
||||
@@ -215,6 +218,87 @@ async function loadEnv(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function extractYamlFrontMatter(content: string): string | null {
|
||||
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*$/m);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
const config: Partial<ExtendConfig> = {};
|
||||
const lines = yaml.split("\n");
|
||||
let currentKey: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
if (trimmed.includes(":") && !trimmed.startsWith("-")) {
|
||||
const colonIdx = trimmed.indexOf(":");
|
||||
const key = trimmed.slice(0, colonIdx).trim();
|
||||
let value = trimmed.slice(colonIdx + 1).trim();
|
||||
|
||||
if (value === "null" || value === "") {
|
||||
value = "null";
|
||||
}
|
||||
|
||||
if (key === "version") {
|
||||
config.version = value === "null" ? 1 : parseInt(value, 10);
|
||||
} else if (key === "default_provider") {
|
||||
config.default_provider = value === "null" ? null : (value as Provider);
|
||||
} else if (key === "default_quality") {
|
||||
config.default_quality = value === "null" ? null : (value as "normal" | "2k");
|
||||
} else if (key === "default_aspect_ratio") {
|
||||
const cleaned = value.replace(/['"]/g, "");
|
||||
config.default_aspect_ratio = cleaned === "null" ? null : cleaned;
|
||||
} else if (key === "default_image_size") {
|
||||
config.default_image_size = value === "null" ? null : (value as "1K" | "2K" | "4K");
|
||||
} else if (key === "default_model") {
|
||||
config.default_model = { google: null, openai: null, dashscope: null, replicate: null };
|
||||
currentKey = "default_model";
|
||||
} else if (currentKey === "default_model" && (key === "google" || key === "openai" || key === "dashscope" || key === "replicate")) {
|
||||
const cleaned = value.replace(/['"]/g, "");
|
||||
config.default_model![key] = cleaned === "null" ? null : cleaned;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async function loadExtendConfig(): Promise<Partial<ExtendConfig>> {
|
||||
const home = homedir();
|
||||
const cwd = process.cwd();
|
||||
|
||||
const paths = [
|
||||
path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
|
||||
path.join(home, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
|
||||
];
|
||||
|
||||
for (const p of paths) {
|
||||
try {
|
||||
const content = await readFile(p, "utf8");
|
||||
const yaml = extractYamlFrontMatter(content);
|
||||
if (!yaml) continue;
|
||||
|
||||
return parseSimpleYaml(yaml);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function mergeConfig(args: CliArgs, extend: Partial<ExtendConfig>): CliArgs {
|
||||
return {
|
||||
...args,
|
||||
provider: args.provider ?? extend.default_provider ?? null,
|
||||
quality: args.quality ?? extend.default_quality ?? null,
|
||||
aspectRatio: args.aspectRatio ?? extend.default_aspect_ratio ?? null,
|
||||
imageSize: args.imageSize ?? extend.default_image_size ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function readPromptFromFiles(files: string[]): Promise<string> {
|
||||
const parts: string[] = [];
|
||||
for (const f of files) {
|
||||
@@ -242,28 +326,67 @@ function normalizeOutputImagePath(p: string): string {
|
||||
}
|
||||
|
||||
function detectProvider(args: CliArgs): Provider {
|
||||
if (args.referenceImages.length > 0 && args.provider && args.provider !== "google" && args.provider !== "openai" && args.provider !== "replicate") {
|
||||
throw new Error(
|
||||
"Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), or --provider replicate."
|
||||
);
|
||||
}
|
||||
|
||||
if (args.provider) return args.provider;
|
||||
|
||||
const hasGoogle = !!(process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY);
|
||||
const hasOpenai = !!process.env.OPENAI_API_KEY;
|
||||
const hasDashscope = !!process.env.DASHSCOPE_API_KEY;
|
||||
const hasReplicate = !!process.env.REPLICATE_API_TOKEN;
|
||||
|
||||
const available = [hasGoogle && "google", hasOpenai && "openai", hasDashscope && "dashscope"].filter(Boolean) as Provider[];
|
||||
if (args.referenceImages.length > 0) {
|
||||
if (hasGoogle) return "google";
|
||||
if (hasOpenai) return "openai";
|
||||
if (hasReplicate) return "replicate";
|
||||
throw new Error(
|
||||
"Reference images require Google, OpenAI or Replicate. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, or REPLICATE_API_TOKEN, or remove --ref."
|
||||
);
|
||||
}
|
||||
|
||||
const available = [hasGoogle && "google", hasOpenai && "openai", hasDashscope && "dashscope", hasReplicate && "replicate"].filter(Boolean) as Provider[];
|
||||
|
||||
if (available.length === 1) return available[0]!;
|
||||
if (available.length > 1) return available[0]!;
|
||||
|
||||
throw new Error(
|
||||
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, or DASHSCOPE_API_KEY.\n" +
|
||||
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, DASHSCOPE_API_KEY, or REPLICATE_API_TOKEN.\n" +
|
||||
"Create ~/.baoyu-skills/.env or <cwd>/.baoyu-skills/.env with your keys."
|
||||
);
|
||||
}
|
||||
|
||||
async function validateReferenceImages(referenceImages: string[]): Promise<void> {
|
||||
for (const refPath of referenceImages) {
|
||||
const fullPath = path.resolve(refPath);
|
||||
try {
|
||||
await access(fullPath);
|
||||
} catch {
|
||||
throw new Error(`Reference image not found: ${fullPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ProviderModule = {
|
||||
getDefaultModel: () => string;
|
||||
generateImage: (prompt: string, model: string, args: CliArgs) => Promise<Uint8Array>;
|
||||
};
|
||||
|
||||
function isRetryableGenerationError(error: unknown): boolean {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
const nonRetryableMarkers = [
|
||||
"Reference image",
|
||||
"not supported",
|
||||
"only supported",
|
||||
"No API key found",
|
||||
"is required",
|
||||
];
|
||||
return !nonRetryableMarkers.some((marker) => msg.includes(marker));
|
||||
}
|
||||
|
||||
async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
|
||||
if (provider === "google") {
|
||||
return (await import("./providers/google")) as ProviderModule;
|
||||
@@ -271,6 +394,9 @@ async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
|
||||
if (provider === "dashscope") {
|
||||
return (await import("./providers/dashscope")) as ProviderModule;
|
||||
}
|
||||
if (provider === "replicate") {
|
||||
return (await import("./providers/replicate")) as ProviderModule;
|
||||
}
|
||||
return (await import("./providers/openai")) as ProviderModule;
|
||||
}
|
||||
|
||||
@@ -283,9 +409,13 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
await loadEnv();
|
||||
const extendConfig = await loadExtendConfig();
|
||||
const mergedArgs = mergeConfig(args, extendConfig);
|
||||
|
||||
let prompt: string | null = args.prompt;
|
||||
if (!prompt && args.promptFiles.length > 0) prompt = await readPromptFromFiles(args.promptFiles);
|
||||
if (!mergedArgs.quality) mergedArgs.quality = "2k";
|
||||
|
||||
let prompt: string | null = mergedArgs.prompt;
|
||||
if (!prompt && mergedArgs.promptFiles.length > 0) prompt = await readPromptFromFiles(mergedArgs.promptFiles);
|
||||
if (!prompt) prompt = await readPromptFromStdin();
|
||||
|
||||
if (!prompt) {
|
||||
@@ -295,27 +425,40 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args.imagePath) {
|
||||
if (!mergedArgs.imagePath) {
|
||||
console.error("Error: --image is required");
|
||||
printUsage();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = detectProvider(args);
|
||||
if (mergedArgs.referenceImages.length > 0) {
|
||||
await validateReferenceImages(mergedArgs.referenceImages);
|
||||
}
|
||||
|
||||
const provider = detectProvider(mergedArgs);
|
||||
const providerModule = await loadProviderModule(provider);
|
||||
const model = args.model || providerModule.getDefaultModel();
|
||||
const outputPath = normalizeOutputImagePath(args.imagePath);
|
||||
|
||||
let model = mergedArgs.model;
|
||||
if (!model && extendConfig.default_model) {
|
||||
if (provider === "google") model = extendConfig.default_model.google ?? null;
|
||||
if (provider === "openai") model = extendConfig.default_model.openai ?? null;
|
||||
if (provider === "dashscope") model = extendConfig.default_model.dashscope ?? null;
|
||||
if (provider === "replicate") model = extendConfig.default_model.replicate ?? null;
|
||||
}
|
||||
model = model || providerModule.getDefaultModel();
|
||||
|
||||
const outputPath = normalizeOutputImagePath(mergedArgs.imagePath);
|
||||
|
||||
let imageData: Uint8Array;
|
||||
let retried = false;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
imageData = await providerModule.generateImage(prompt, model, args);
|
||||
imageData = await providerModule.generateImage(prompt, model, mergedArgs);
|
||||
break;
|
||||
} catch (e) {
|
||||
if (!retried) {
|
||||
if (!retried && isRetryableGenerationError(e)) {
|
||||
retried = true;
|
||||
console.error("Generation failed, retrying...");
|
||||
continue;
|
||||
@@ -328,7 +471,7 @@ async function main(): Promise<void> {
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(outputPath, imageData);
|
||||
|
||||
if (args.json) {
|
||||
if (mergedArgs.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
|
||||
@@ -58,7 +58,9 @@ export async function generateImage(
|
||||
if (!apiKey) throw new Error("DASHSCOPE_API_KEY is required");
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
console.error("Warning: Reference images not yet supported with DashScope, ignoring.");
|
||||
throw new Error(
|
||||
"Reference images are not supported with DashScope provider in baoyu-image-gen. Use --provider google with a Gemini multimodal model."
|
||||
);
|
||||
}
|
||||
|
||||
const size = args.size ? normalizeSize(args.size) : getSizeFromAspectRatio(args.aspectRatio, args.quality);
|
||||
|
||||
@@ -216,13 +216,17 @@ export async function generateImage(
|
||||
): Promise<Uint8Array> {
|
||||
if (isGoogleImagen(model)) {
|
||||
if (args.referenceImages.length > 0) {
|
||||
console.error("Warning: Reference images not supported with Imagen models, ignoring.");
|
||||
throw new Error(
|
||||
"Reference images are not supported with Imagen models. Use gemini-3-pro-image-preview or gemini-3-flash-preview."
|
||||
);
|
||||
}
|
||||
return generateWithImagen(prompt, model, args);
|
||||
}
|
||||
|
||||
if (!isGoogleMultimodal(model) && args.referenceImages.length > 0) {
|
||||
console.error("Warning: Reference images are only supported with Gemini multimodal models.");
|
||||
throw new Error(
|
||||
"Reference images are only supported with Gemini multimodal models. Use gemini-3-pro-image-preview or gemini-3-flash-preview."
|
||||
);
|
||||
}
|
||||
|
||||
return generateWithGemini(prompt, model, args);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { CliArgs } from "../types";
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.OPENAI_IMAGE_MODEL || "gpt-image-1.5";
|
||||
}
|
||||
|
||||
type OpenAIImageResponse = { data: Array<{ url?: string; b64_json?: string }> };
|
||||
|
||||
function parseAspectRatio(ar: string): { width: number; height: number } | null {
|
||||
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
||||
if (!match) return null;
|
||||
@@ -66,20 +70,32 @@ export async function generateImage(
|
||||
|
||||
if (!apiKey) throw new Error("OPENAI_API_KEY is required");
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
console.error("Warning: Reference images not supported with OpenAI, ignoring.");
|
||||
}
|
||||
|
||||
const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
|
||||
|
||||
const body: Record<string, any> = {
|
||||
model,
|
||||
prompt,
|
||||
size,
|
||||
};
|
||||
if (args.referenceImages.length > 0) {
|
||||
if (model.includes("dall-e-2") || model.includes("dall-e-3")) {
|
||||
throw new Error(
|
||||
"Reference images with OpenAI in this skill require GPT Image models. Use --model gpt-image-1.5 (or another gpt-image model)."
|
||||
);
|
||||
}
|
||||
return generateWithOpenAIEdits(baseURL, apiKey, prompt, model, size, args.referenceImages, args.quality);
|
||||
}
|
||||
|
||||
return generateWithOpenAIGenerations(baseURL, apiKey, prompt, model, size, args.quality);
|
||||
}
|
||||
|
||||
async function generateWithOpenAIGenerations(
|
||||
baseURL: string,
|
||||
apiKey: string,
|
||||
prompt: string,
|
||||
model: string,
|
||||
size: string,
|
||||
quality: CliArgs["quality"]
|
||||
): Promise<Uint8Array> {
|
||||
const body: Record<string, any> = { model, prompt, size };
|
||||
|
||||
if (model.includes("dall-e-3")) {
|
||||
body.quality = args.quality === "2k" ? "hd" : "standard";
|
||||
body.quality = quality === "2k" ? "hd" : "standard";
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseURL}/images/generations`, {
|
||||
@@ -96,7 +112,62 @@ export async function generateImage(
|
||||
throw new Error(`OpenAI API error: ${err}`);
|
||||
}
|
||||
|
||||
const result = (await res.json()) as { data: Array<{ url?: string; b64_json?: string }> };
|
||||
const result = (await res.json()) as OpenAIImageResponse;
|
||||
return extractImageFromResponse(result);
|
||||
}
|
||||
|
||||
async function generateWithOpenAIEdits(
|
||||
baseURL: string,
|
||||
apiKey: string,
|
||||
prompt: string,
|
||||
model: string,
|
||||
size: string,
|
||||
referenceImages: string[],
|
||||
quality: CliArgs["quality"]
|
||||
): Promise<Uint8Array> {
|
||||
const form = new FormData();
|
||||
form.append("model", model);
|
||||
form.append("prompt", prompt);
|
||||
form.append("size", size);
|
||||
|
||||
if (model.includes("gpt-image")) {
|
||||
form.append("quality", quality === "2k" ? "high" : "medium");
|
||||
}
|
||||
|
||||
for (const refPath of referenceImages) {
|
||||
const bytes = await readFile(refPath);
|
||||
const filename = path.basename(refPath);
|
||||
const mimeType = getMimeType(filename);
|
||||
const blob = new Blob([bytes], { type: mimeType });
|
||||
form.append("image[]", blob, filename);
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseURL}/images/edits`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`OpenAI edits API error: ${err}`);
|
||||
}
|
||||
|
||||
const result = (await res.json()) as OpenAIImageResponse;
|
||||
return extractImageFromResponse(result);
|
||||
}
|
||||
|
||||
function getMimeType(filename: string): string {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
||||
if (ext === ".webp") return "image/webp";
|
||||
if (ext === ".gif") return "image/gif";
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
async function extractImageFromResponse(result: OpenAIImageResponse): Promise<Uint8Array> {
|
||||
const img = result.data[0];
|
||||
|
||||
if (img?.b64_json) {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { CliArgs } from "../types";
|
||||
|
||||
const DEFAULT_MODEL = "google/nano-banana-pro";
|
||||
const SYNC_WAIT_SECONDS = 60;
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const MAX_POLL_MS = 300_000;
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.REPLICATE_IMAGE_MODEL || DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
function getApiToken(): string | null {
|
||||
return process.env.REPLICATE_API_TOKEN || null;
|
||||
}
|
||||
|
||||
function getBaseUrl(): string {
|
||||
const base = process.env.REPLICATE_BASE_URL || "https://api.replicate.com";
|
||||
return base.replace(/\/+$/g, "");
|
||||
}
|
||||
|
||||
function parseModelId(model: string): { owner: string; name: string; version: string | null } {
|
||||
const [ownerName, version] = model.split(":");
|
||||
const parts = ownerName!.split("/");
|
||||
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
||||
throw new Error(
|
||||
`Invalid Replicate model format: "${model}". Expected "owner/name" or "owner/name:version".`
|
||||
);
|
||||
}
|
||||
return { owner: parts[0], name: parts[1], version: version || null };
|
||||
}
|
||||
|
||||
function buildInput(prompt: string, args: CliArgs, referenceImages: string[]): Record<string, unknown> {
|
||||
const input: Record<string, unknown> = { prompt };
|
||||
|
||||
if (args.aspectRatio) {
|
||||
input.aspect_ratio = args.aspectRatio;
|
||||
}
|
||||
|
||||
if (args.n > 1) {
|
||||
input.number_of_images = args.n;
|
||||
}
|
||||
|
||||
input.output_format = "png";
|
||||
|
||||
if (referenceImages.length > 0) {
|
||||
if (referenceImages.length === 1) {
|
||||
input.image = referenceImages[0];
|
||||
} else {
|
||||
for (let i = 0; i < referenceImages.length; i++) {
|
||||
input[`image${i > 0 ? i + 1 : ""}`] = referenceImages[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
async function readImageAsDataUrl(p: string): Promise<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:${mimeType};base64,${buf.toString("base64")}`;
|
||||
}
|
||||
|
||||
type PredictionResponse = {
|
||||
id: string;
|
||||
status: string;
|
||||
output: unknown;
|
||||
error: string | null;
|
||||
urls?: { get?: string };
|
||||
};
|
||||
|
||||
async function createPrediction(
|
||||
apiToken: string,
|
||||
model: { owner: string; name: string; version: string | null },
|
||||
input: Record<string, unknown>,
|
||||
sync: boolean
|
||||
): Promise<PredictionResponse> {
|
||||
const baseUrl = getBaseUrl();
|
||||
|
||||
let url: string;
|
||||
const body: Record<string, unknown> = { input };
|
||||
|
||||
if (model.version) {
|
||||
url = `${baseUrl}/v1/predictions`;
|
||||
body.version = model.version;
|
||||
} else {
|
||||
url = `${baseUrl}/v1/models/${model.owner}/${model.name}/predictions`;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (sync) {
|
||||
headers["Prefer"] = `wait=${SYNC_WAIT_SECONDS}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Replicate API error (${res.status}): ${err}`);
|
||||
}
|
||||
|
||||
return (await res.json()) as PredictionResponse;
|
||||
}
|
||||
|
||||
async function pollPrediction(apiToken: string, getUrl: string): Promise<PredictionResponse> {
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < MAX_POLL_MS) {
|
||||
const res = await fetch(getUrl, {
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Replicate poll error (${res.status}): ${err}`);
|
||||
}
|
||||
|
||||
const prediction = (await res.json()) as PredictionResponse;
|
||||
|
||||
if (prediction.status === "succeeded") return prediction;
|
||||
if (prediction.status === "failed" || prediction.status === "canceled") {
|
||||
throw new Error(`Replicate prediction ${prediction.status}: ${prediction.error || "unknown error"}`);
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
||||
}
|
||||
|
||||
throw new Error(`Replicate prediction timed out after ${MAX_POLL_MS / 1000}s`);
|
||||
}
|
||||
|
||||
function extractOutputUrl(prediction: PredictionResponse): string {
|
||||
const output = prediction.output;
|
||||
|
||||
if (typeof output === "string") return output;
|
||||
|
||||
if (Array.isArray(output)) {
|
||||
const first = output[0];
|
||||
if (typeof first === "string") return first;
|
||||
}
|
||||
|
||||
if (output && typeof output === "object" && "url" in output) {
|
||||
const url = (output as Record<string, unknown>).url;
|
||||
if (typeof url === "string") return url;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected Replicate output format: ${JSON.stringify(output)}`);
|
||||
}
|
||||
|
||||
async function downloadImage(url: string): Promise<Uint8Array> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to download image from Replicate: ${res.status}`);
|
||||
const buf = await res.arrayBuffer();
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
export async function generateImage(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs
|
||||
): Promise<Uint8Array> {
|
||||
const apiToken = getApiToken();
|
||||
if (!apiToken) throw new Error("REPLICATE_API_TOKEN is required. Get one at https://replicate.com/account/api-tokens");
|
||||
|
||||
const parsedModel = parseModelId(model);
|
||||
|
||||
const refDataUrls: string[] = [];
|
||||
for (const refPath of args.referenceImages) {
|
||||
refDataUrls.push(await readImageAsDataUrl(refPath));
|
||||
}
|
||||
|
||||
const input = buildInput(prompt, args, refDataUrls);
|
||||
|
||||
console.log(`Generating image with Replicate (${model})...`);
|
||||
|
||||
let prediction = await createPrediction(apiToken, parsedModel, input, true);
|
||||
|
||||
if (prediction.status !== "succeeded") {
|
||||
if (!prediction.urls?.get) {
|
||||
throw new Error("Replicate prediction did not return a poll URL");
|
||||
}
|
||||
console.log("Waiting for prediction to complete...");
|
||||
prediction = await pollPrediction(apiToken, prediction.urls.get);
|
||||
}
|
||||
|
||||
console.log("Generation completed.");
|
||||
|
||||
const outputUrl = extractOutputUrl(prediction);
|
||||
return downloadImage(outputUrl);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type Provider = "google" | "openai" | "dashscope";
|
||||
export type Provider = "google" | "openai" | "dashscope" | "replicate";
|
||||
export type Quality = "normal" | "2k";
|
||||
|
||||
export type CliArgs = {
|
||||
@@ -9,10 +9,24 @@ export type CliArgs = {
|
||||
model: string | null;
|
||||
aspectRatio: string | null;
|
||||
size: string | null;
|
||||
quality: Quality;
|
||||
quality: Quality | null;
|
||||
imageSize: string | null;
|
||||
referenceImages: string[];
|
||||
n: number;
|
||||
json: boolean;
|
||||
help: boolean;
|
||||
};
|
||||
|
||||
export type ExtendConfig = {
|
||||
version: number;
|
||||
default_provider: Provider | null;
|
||||
default_quality: Quality | null;
|
||||
default_aspect_ratio: string | null;
|
||||
default_image_size: "1K" | "2K" | "4K" | null;
|
||||
default_model: {
|
||||
google: string | null;
|
||||
openai: string | null;
|
||||
dashscope: string | null;
|
||||
replicate: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: baoyu-infographic
|
||||
description: Generates professional infographics with 20 layout types and 17 visual styles. Analyzes content, recommends layout×style combinations, and generates publication-ready infographics. Use when user asks to create "infographic", "信息图", "visual summary", or "可视化".
|
||||
description: Generates professional infographics with 21 layout types and 20 visual styles. Analyzes content, recommends layout×style combinations, and generates publication-ready infographics. Use when user asks to create "infographic", "信息图", "visual summary", "可视化", or "高密度信息大图".
|
||||
---
|
||||
|
||||
# Infographic Generator
|
||||
@@ -20,8 +20,8 @@ Two dimensions: **layout** (information structure) × **style** (visual aestheti
|
||||
|
||||
| Option | Values |
|
||||
|--------|--------|
|
||||
| `--layout` | 20 options (see Layout Gallery), default: bento-grid |
|
||||
| `--style` | 17 options (see Style Gallery), default: craft-handmade |
|
||||
| `--layout` | 21 options (see Layout Gallery), default: bento-grid |
|
||||
| `--style` | 20 options (see Style Gallery), default: craft-handmade |
|
||||
| `--aspect` | landscape (16:9), portrait (9:16), square (1:1) |
|
||||
| `--lang` | en, zh, ja, etc. |
|
||||
|
||||
@@ -49,6 +49,7 @@ Two dimensions: **layout** (information structure) × **style** (visual aestheti
|
||||
| `venn-diagram` | Overlapping concepts |
|
||||
| `winding-roadmap` | Journey, milestones |
|
||||
| `circular-flow` | Cycles, recurring processes |
|
||||
| `dense-modules` | High-density modules, data-rich guides |
|
||||
|
||||
Full definitions: `references/layouts/<layout>.md`
|
||||
|
||||
@@ -73,6 +74,9 @@ Full definitions: `references/layouts/<layout>.md`
|
||||
| `ikea-manual` | Minimal line art |
|
||||
| `knolling` | Organized flat-lay |
|
||||
| `lego-brick` | Toy brick construction |
|
||||
| `pop-laboratory` | Blueprint grid, coordinate markers, lab precision |
|
||||
| `morandi-journal` | Hand-drawn doodle, warm Morandi tones |
|
||||
| `retro-pop-grid` | 1970s retro pop art, Swiss grid, thick outlines |
|
||||
|
||||
Full definitions: `references/styles/<style>.md`
|
||||
|
||||
@@ -92,9 +96,23 @@ Full definitions: `references/styles/<style>.md`
|
||||
| Educational | `bento-grid` + `chalkboard` |
|
||||
| Journey | `winding-roadmap` + `storybook-watercolor` |
|
||||
| Categories | `periodic-table` + `bold-graphic` |
|
||||
| Product Guide | `dense-modules` + `morandi-journal` |
|
||||
| Technical Guide | `dense-modules` + `pop-laboratory` |
|
||||
| Trendy Guide | `dense-modules` + `retro-pop-grid` |
|
||||
|
||||
Default: `bento-grid` + `craft-handmade`
|
||||
|
||||
## Keyword Shortcuts
|
||||
|
||||
When user input contains these keywords, **auto-select** the associated layout and offer associated styles as top recommendations in Step 3. Skip content-based layout inference for matched keywords.
|
||||
|
||||
If a shortcut has **Prompt Notes**, append them to the generated prompt (Step 5) as additional style instructions.
|
||||
|
||||
| User Keyword | Layout | Recommended Styles | Default Aspect | Prompt Notes |
|
||||
|--------------|--------|--------------------|----------------|--------------|
|
||||
| 高密度信息大图 / high-density-info | `dense-modules` | `morandi-journal`, `pop-laboratory`, `retro-pop-grid` | portrait | — |
|
||||
| 信息图 / infographic | `bento-grid` | `craft-handmade` | landscape | Minimalist: clean canvas, ample whitespace, no complex background textures. Simple cartoon elements and icons only. |
|
||||
|
||||
## Output Structure
|
||||
|
||||
```
|
||||
@@ -176,7 +194,9 @@ See `references/structured-content-template.md` for detailed format.
|
||||
|
||||
### Step 3: Recommend Combinations
|
||||
|
||||
Recommend 3-5 layout×style combinations based on:
|
||||
**3.1 Check Keyword Shortcuts first**: If user input matches a keyword from the **Keyword Shortcuts** table, auto-select the associated layout and prioritize associated styles as top recommendations. Skip content-based layout inference.
|
||||
|
||||
**3.2 Otherwise**, recommend 3-5 layout×style combinations based on:
|
||||
- Data structure → matching layout
|
||||
- Content tone → matching style
|
||||
- Audience expectations
|
||||
@@ -222,8 +242,8 @@ Report: topic, layout, style, aspect, language, output path, files created.
|
||||
- `references/analysis-framework.md` - Analysis methodology
|
||||
- `references/structured-content-template.md` - Content format
|
||||
- `references/base-prompt.md` - Prompt template
|
||||
- `references/layouts/<layout>.md` - 20 layout definitions
|
||||
- `references/styles/<style>.md` - 17 style definitions
|
||||
- `references/layouts/<layout>.md` - 21 layout definitions
|
||||
- `references/styles/<style>.md` - 20 style definitions
|
||||
|
||||
## Extension Support
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ Approach content analysis as a **world-class instructional designer**:
|
||||
| **Cycle/Loop** | Recurring processes, feedback loops | circular-flow | craft-handmade, technical-schematic |
|
||||
| **System/Structure** | Components, architecture, anatomy | structural-breakdown, bento-grid | technical-schematic, ikea-manual |
|
||||
| **Journey/Narrative** | Stories, user flows, milestones | winding-roadmap, story-mountain | storybook-watercolor, comic-strip |
|
||||
| **Overview/Summary** | Multiple topics, feature highlights | bento-grid, periodic-table | chalkboard, bold-graphic |
|
||||
| **Overview/Summary** | Multiple topics, feature highlights | bento-grid, periodic-table, dense-modules | chalkboard, bold-graphic |
|
||||
| **Product/Buying Guide** | Multi-dimension comparisons, specs, pitfalls | dense-modules | morandi-journal, pop-laboratory, retro-pop-grid |
|
||||
|
||||
### 2. Learning Objective Identification
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# dense-modules
|
||||
|
||||
High-density modular layout with 6-7 typed information modules packed with concrete data.
|
||||
|
||||
## Structure
|
||||
|
||||
- 6-7 distinct modules per image, each serving a specific information function
|
||||
- Every module contains concrete data: brand names, numbers, percentages, parameters
|
||||
- Minimal whitespace—compact spacing prioritized over breathing room
|
||||
- Smaller text acceptable to maximize information density
|
||||
- Each module identified by coordinate label or section marker (e.g., MOD-1, SEC-A)
|
||||
|
||||
## Module Archetypes
|
||||
|
||||
| Module | Purpose | Content Requirements |
|
||||
|--------|---------|---------------------|
|
||||
| **Brand/Selection Array** | Grid of options with recommendations | 4-8 items with icons, names, brief descriptions; highlight "best choice" |
|
||||
| **Specification Scale** | Quality/measurement gauge | 3-5 levels with precise numerical increments, quality indicators (emoji faces, checkmarks) |
|
||||
| **Deep Dive/Detail** | Technical breakdown of key item | Zoom-in callouts, internal components, cross-section or exploded view |
|
||||
| **Scenario Comparison** | Side-by-side use cases | 3-6 scenarios with specific recommendations and data per scenario |
|
||||
| **Identification Tips** | How-to checklist | 3-5 inspection methods: look/test/check/ask format |
|
||||
| **Warning/Pitfall Zone** | Critical mistakes to avoid | 3-5 pitfalls with consequences, 1-2 correct approaches; high visual contrast |
|
||||
| **Quick Reference** | Compact summary | Dense table, one-line summaries, decision flowchart, or key takeaways |
|
||||
|
||||
## Variants
|
||||
|
||||
| Variant | Focus | Visual Emphasis |
|
||||
|---------|-------|-----------------|
|
||||
| **Coordinate-labeled** | Precision and systematicity | Each module has alphanumeric coordinate (A-01, B-05, C-12), ruler/axis markers |
|
||||
| **Grid-cell** | Order and structure | Modules in strict rectangular cells divided by thick lines, Swiss grid feel |
|
||||
| **Free-flowing** | Organic density | Magazine-style layout with dotted frames, varying module sizes, connected by arrows |
|
||||
|
||||
## Best For
|
||||
|
||||
- Product selection guides and buying guides
|
||||
- Multi-dimensional comparison content
|
||||
- Data-rich educational materials
|
||||
- "Avoid pitfalls" / "complete guide" formats
|
||||
- Content targeting platforms like Xiaohongshu with high-density visual requirements
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Module boundary markers (thick lines, dotted frames, or coordinate grids)
|
||||
- Quality indicators per module (emoji faces, checkmarks, crosses, crowns)
|
||||
- Data callout boxes with highlighted numbers
|
||||
- Comparison arrows and progression indicators
|
||||
- Warning/alert visual markers for pitfall modules
|
||||
- Metadata in corners (page numbers, timestamps, small barcodes)
|
||||
|
||||
## Text Placement
|
||||
|
||||
- Main title at top, prominent and impactful
|
||||
- Subtitle with module count ("X大维度全面解析...")
|
||||
- Module headers inside colored badges or labeled frames
|
||||
- Body text compact, multiple columns within modules
|
||||
- Numbers highlighted with accent colors, slightly larger than body text
|
||||
|
||||
## Information Density Rules
|
||||
|
||||
- Every corner should contain useful information or metadata
|
||||
- No decorative-only empty space
|
||||
- Text size may be reduced to fit more content—information over font size
|
||||
- Each module must have specific data points, not generic descriptions
|
||||
- Balance between density and readability: dense but organized
|
||||
|
||||
## Recommended Pairings
|
||||
|
||||
- `pop-laboratory`: Technical precision with coordinate markers and blueprint grid
|
||||
- `morandi-journal`: Hand-drawn warmth with doodle illustrations and organic frames
|
||||
- `retro-pop-grid`: 1970s pop art with strict grid cells and bold contrast
|
||||
- `corporate-memphis`: Clean business feel for product comparisons
|
||||
- `technical-schematic`: Engineering precision for technical product guides
|
||||
@@ -0,0 +1,60 @@
|
||||
# morandi-journal
|
||||
|
||||
Hand-drawn doodle illustration with warm Morandi color tones and cozy bullet journal aesthetic.
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Background: Warm cream/beige with subtle paper texture (#F5F0E6)
|
||||
- Primary: Muted teal/sage green (#7BA3A8) for headers and frames
|
||||
- Secondary: Warm terracotta/orange (#D4956A) for highlights and numbers
|
||||
- Line art: Dark charcoal brown (#4A4540)
|
||||
- Soft highlights: Pale yellow (#F5E6C8)
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Hand-drawn doodle illustrations with organic, slightly imperfect ink lines
|
||||
- Washi tape strip decorations (diagonal stripes pattern, beige and brown)
|
||||
- Rounded card containers for brand/option items
|
||||
- Hand-drawn rulers, scales, and progress bars with emoji quality indicators
|
||||
- Smiley/frowny faces as quality markers (😊✓ 😐 ☹️✗)
|
||||
- Dotted line frames around sections
|
||||
- Connecting arrows and dotted lines between modules
|
||||
- Corner decorations: tiny houses, stars, sparkles, clouds
|
||||
- Wavy line dividers between sections
|
||||
- Callout bubbles for tips
|
||||
- Magnifying glass icons for identification tips
|
||||
- Thumbs up/down icons (hand-drawn style)
|
||||
|
||||
## Variants
|
||||
|
||||
| Variant | Focus | Visual Emphasis |
|
||||
|---------|-------|-----------------|
|
||||
| **Cozy journal** | Maximum warmth | More washi tape, stickers, decorative doodles |
|
||||
| **Clean sketch** | Readability | Cleaner lines, less decoration, more structured |
|
||||
|
||||
## Typography
|
||||
|
||||
- Main title: Bold hand-lettered calligraphy style with decorative flourishes
|
||||
- Module headers: Clean handwritten text in white on dark teal rounded badge (#6B9080)
|
||||
- Body text: Neat handwritten print style, easy to read
|
||||
- Numbers: Highlighted in terracotta (#D4956A), slightly larger than body
|
||||
|
||||
## Style Enforcement
|
||||
|
||||
- All imagery must maintain hand-drawn/doodle aesthetic—no digital precision
|
||||
- Organic, slightly imperfect shapes throughout
|
||||
- Sketch-like quality with visible line weight variations
|
||||
- Warm and cozy journal feel, not clinical or corporate
|
||||
|
||||
## Avoid
|
||||
|
||||
- Flat vector icons or emoji
|
||||
- Clean geometric shapes
|
||||
- Stock illustration style
|
||||
- Strict grid layout
|
||||
- Pure white background
|
||||
- Digital/corporate look
|
||||
|
||||
## Best For
|
||||
|
||||
Product selection guides, lifestyle content, educational overviews, consumer-facing comparison content, Xiaohongshu-style posts
|
||||
@@ -0,0 +1,48 @@
|
||||
# pop-laboratory
|
||||
|
||||
Lab manual precision meets pop art color impact—coordinate systems, technical diagrams, and fluorescent accents on blueprint grid.
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Background: Professional grayish-white with faint blueprint grid texture (#F2F2F2)
|
||||
- Primary: Muted teal/sage green (#B8D8BE) for major functional blocks and data zones
|
||||
- High-alert accent: Vibrant fluorescent pink (#E91E63) strictly for warnings, critical data, or "winner" highlights
|
||||
- Marker highlights: Vivid lemon yellow (#FFF200) as translucent highlighter effect for keywords
|
||||
- Line art: Ultra-fine charcoal brown (#2D2926) for technical grids, coordinates, and hairlines
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Coordinate-style labels on every module (e.g., R-20, G-02, SEC-08)
|
||||
- Technical diagrams: exploded views, cross-sections with anchor points, architectural skeletal lines
|
||||
- Vertical/horizontal rulers with precise markers (0.5mm, 1.8mm, 45°)
|
||||
- "Marker-over-print" effect: color blocks slightly offset from text, postmodern print feel
|
||||
- Cross-hair targets, mathematical symbols (Σ, Δ, ∞), directional arrows (X/Y axis)
|
||||
- Microscopic detail annotations alongside macroscopic bold headers
|
||||
- Corner metadata: tiny barcodes, timestamps, technical parameters
|
||||
- High contrast between massive bold headers and tiny 8pt-style annotations
|
||||
|
||||
## Typography
|
||||
|
||||
- Headers: Bold brutalist characters, high visual impact
|
||||
- Body: Professional sans-serif or crisp technical print
|
||||
- Numbers: Large, highlighted with yellow or blue to stand out
|
||||
- Annotations: Ultra-crisp, small technical labels
|
||||
|
||||
## Style Enforcement
|
||||
|
||||
- Strictly systematic color usage: only teal, pink, yellow, charcoal—no rainbow palette
|
||||
- Sufficient fine grid lines and coordinate annotations throughout
|
||||
- Maintain tension between large impactful headers and small precise parameters
|
||||
- Lab manual aesthetic: mix of microscopic details and macroscopic data
|
||||
|
||||
## Avoid
|
||||
|
||||
- Cute or cartoonish doodles
|
||||
- Soft pastels or generic textures
|
||||
- Empty white space
|
||||
- Flat vector stock icons
|
||||
- Organic or hand-drawn imperfections
|
||||
|
||||
## Best For
|
||||
|
||||
Technical product guides, specification comparisons, precision-focused data visualization, engineering-adjacent content
|
||||
@@ -0,0 +1,47 @@
|
||||
# retro-pop-grid
|
||||
|
||||
1970s retro pop art with strict Swiss international grid, thick black outlines, and flat color blocks.
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Background: Warm vintage cream/beige (#F5F0E6)
|
||||
- Flat accents: Salmon pink, sky blue, mustard yellow, mint green—all muted retro tones
|
||||
- Contrast blocks: Solid pure black (#000000) and solid pure white (#FFFFFF) used strategically for extreme contrast
|
||||
- Line art and outlines: Solid thick black
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Uniform thick black outlines on all illustrations, text boxes, and grid dividers
|
||||
- Pure 2D flat vector aesthetic with subtle screen print texture
|
||||
- Strict Swiss international grid: poster divided into square and rectangular cells by thick black lines
|
||||
- Black-background cells with white text for warnings or key categories (inverted contrast)
|
||||
- Geometric fill patterns in empty cells: checkerboards, diagonal lines, dots
|
||||
- Flat abstract symbols, warning signs, keyholes, stars, arrows
|
||||
- Vintage comic-style smiley/frowny faces for quality indicators
|
||||
- Colored cells used for breathing room—some with minimal/no content
|
||||
|
||||
## Typography
|
||||
|
||||
- Headers: Bold brutalist or retro thick display fonts, high legibility
|
||||
- Body: Clean sans-serif, structured typographic alignment
|
||||
- Decorative English text acceptable for stylistic labels ("WARNING", "INFO", "BEST")
|
||||
- All content text in specified language
|
||||
|
||||
## Style Enforcement
|
||||
|
||||
- Absolutely no gradients, shading, drop shadows, or 3D effects
|
||||
- Everything anchored in grid cells—no floating or unorganized elements
|
||||
- Maintain 1970s retro pop art and underground comic illustration feel
|
||||
- Visual density balanced with rhythmic grid—some cells intentionally sparse for contrast
|
||||
|
||||
## Avoid
|
||||
|
||||
- 3D rendering, realistic details, gradients, soft shadows
|
||||
- Soft, thin, or sketch-like pencil lines
|
||||
- Free-flowing, unorganized, or floating layouts (everything must be grid-anchored)
|
||||
- Pure white background canvas
|
||||
- Organic or hand-drawn imperfections
|
||||
|
||||
## Best For
|
||||
|
||||
Trendy product guides, design-conscious content, visually striking comparisons, content targeting design-savvy audiences, bold social media posts
|
||||
@@ -68,9 +68,24 @@ Use `AskUserQuestion` to ask whether to format first. Formatting can fix:
|
||||
|
||||
**If user declines**: Continue with original file.
|
||||
|
||||
### Step 1: Confirm Theme
|
||||
### Step 1: Determine Theme
|
||||
|
||||
Before converting, use AskUserQuestion to confirm the theme (unless user already specified):
|
||||
**Theme resolution order** (first match wins):
|
||||
1. User explicitly specified theme (CLI `--theme` or conversation)
|
||||
2. EXTEND.md `default_theme` (this skill's own EXTEND.md, checked in Step 0)
|
||||
3. `baoyu-post-to-wechat` EXTEND.md `default_theme` (cross-skill fallback)
|
||||
4. If none found → use AskUserQuestion to confirm
|
||||
|
||||
**Cross-skill EXTEND.md check** (only if this skill's EXTEND.md has no `default_theme`):
|
||||
|
||||
```bash
|
||||
# Check baoyu-post-to-wechat EXTEND.md for default_theme
|
||||
test -f "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md" && grep -o 'default_theme:.*' "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md"
|
||||
```
|
||||
|
||||
**If theme is resolved from EXTEND.md**: Use it directly, do NOT ask the user.
|
||||
|
||||
**If no default found**: Use AskUserQuestion to confirm:
|
||||
|
||||
| Theme | Description |
|
||||
|-------|-------------|
|
||||
|
||||
@@ -126,7 +126,18 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
|
||||
|
||||
const { frontmatter, body } = parseFrontmatter(content);
|
||||
|
||||
let title = options?.title ?? frontmatter.title ?? '';
|
||||
const stripQuotes = (s?: string): string => {
|
||||
if (!s) return '';
|
||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||
return s.slice(1, -1);
|
||||
}
|
||||
if ((s.startsWith('\u201c') && s.endsWith('\u201d')) || (s.startsWith('\u2018') && s.endsWith('\u2019'))) {
|
||||
return s.slice(1, -1);
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
let title = options?.title ?? stripQuotes(frontmatter.title) ?? '';
|
||||
if (!title) {
|
||||
const lines = body.split('\n');
|
||||
for (const line of lines) {
|
||||
@@ -138,8 +149,8 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
|
||||
}
|
||||
}
|
||||
if (!title) title = path.basename(markdownPath, path.extname(markdownPath));
|
||||
const author = frontmatter.author || '';
|
||||
let summary = frontmatter.description || frontmatter.summary || '';
|
||||
const author = stripQuotes(frontmatter.author);
|
||||
let summary = stripQuotes(frontmatter.description) || stripQuotes(frontmatter.summary);
|
||||
|
||||
if (!summary) {
|
||||
const lines = body.split('\n');
|
||||
|
||||
@@ -708,14 +708,28 @@ function normalizeThemeCss(css: string): string {
|
||||
return stripOutputScope(css);
|
||||
}
|
||||
|
||||
function buildHtmlDocument(title: string, css: string, html: string): string {
|
||||
return [
|
||||
interface HtmlDocumentMeta {
|
||||
title: string;
|
||||
author?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
function buildHtmlDocument(meta: HtmlDocumentMeta, css: string, html: string): string {
|
||||
const lines = [
|
||||
"<!doctype html>",
|
||||
"<html>",
|
||||
"<head>",
|
||||
' <meta charset="utf-8" />',
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
||||
` <title>${title}</title>`,
|
||||
` <title>${meta.title}</title>`,
|
||||
];
|
||||
if (meta.author) {
|
||||
lines.push(` <meta name="author" content="${meta.author}" />`);
|
||||
}
|
||||
if (meta.description) {
|
||||
lines.push(` <meta name="description" content="${meta.description}" />`);
|
||||
}
|
||||
lines.push(
|
||||
` <style>${css}</style>`,
|
||||
"</head>",
|
||||
"<body>",
|
||||
@@ -723,8 +737,9 @@ function buildHtmlDocument(title: string, css: string, html: string): string {
|
||||
html,
|
||||
" </div>",
|
||||
"</body>",
|
||||
"</html>",
|
||||
].join("\n");
|
||||
"</html>"
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function inlineCss(html: string): Promise<string> {
|
||||
@@ -814,6 +829,7 @@ async function main(): Promise<void> {
|
||||
const markdown = fs.readFileSync(inputPath, "utf-8");
|
||||
|
||||
const renderer = initRenderer({});
|
||||
const { yamlData } = renderer.parseFrontMatterAndContent(markdown);
|
||||
const { html: baseHtml, readingTime: readingTimeResult } = renderMarkdown(
|
||||
markdown,
|
||||
renderer
|
||||
@@ -823,8 +839,23 @@ async function main(): Promise<void> {
|
||||
content = removeFirstHeading(content);
|
||||
}
|
||||
|
||||
const title = path.basename(outputPath, ".html");
|
||||
const html = buildHtmlDocument(title, css, content);
|
||||
const stripQuotes = (s?: string): string | undefined => {
|
||||
if (!s) return s;
|
||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||
return s.slice(1, -1);
|
||||
}
|
||||
if ((s.startsWith('\u201c') && s.endsWith('\u201d')) || (s.startsWith('\u2018') && s.endsWith('\u2019'))) {
|
||||
return s.slice(1, -1);
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
const meta: HtmlDocumentMeta = {
|
||||
title: stripQuotes(yamlData.title) || path.basename(outputPath, ".html"),
|
||||
author: stripQuotes(yamlData.author),
|
||||
description: stripQuotes(yamlData.description) || stripQuotes(yamlData.summary),
|
||||
};
|
||||
const html = buildHtmlDocument(meta, css, content);
|
||||
const inlinedHtml = normalizeInlineCss(await inlineCss(html));
|
||||
const finalHtml = modifyHtmlStructure(inlinedHtml);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: baoyu-post-to-wechat
|
||||
description: Posts content to WeChat Official Account (微信公众号) via API or Chrome CDP. Supports article posting (文章) with HTML, markdown, or plain text input, and image-text posting (图文) with multiple images. Use when user mentions "发布公众号", "post to wechat", "微信公众号", or "图文/文章".
|
||||
description: Posts content to WeChat Official Account (微信公众号) via API or Chrome CDP. Supports article posting (文章) with HTML, markdown, or plain text input, and image-text posting (贴图, formerly 图文) with multiple images. Use when user mentions "发布公众号", "post to wechat", "微信公众号", or "贴图/图文/文章".
|
||||
---
|
||||
|
||||
# Post to WeChat Official Account
|
||||
@@ -18,6 +18,7 @@ description: Posts content to WeChat Official Account (微信公众号) via API
|
||||
| `scripts/wechat-browser.ts` | Image-text posts (图文) |
|
||||
| `scripts/wechat-article.ts` | Article posting via browser (文章) |
|
||||
| `scripts/wechat-api.ts` | Article posting via API (文章) |
|
||||
| `scripts/check-permissions.ts` | Verify environment & permissions |
|
||||
|
||||
## Preferences (EXTEND.md)
|
||||
|
||||
@@ -44,10 +45,60 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md" && echo "user"
|
||||
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||
│ Found │ Read, parse, apply settings │
|
||||
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||
│ Not found │ Use defaults │
|
||||
│ Not found │ Run first-time setup ([references/config/first-time-setup.md](references/config/first-time-setup.md)) → Save → Continue │
|
||||
└───────────┴───────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
**EXTEND.md Supports**: Default theme | Default publishing method (api/browser) | Default author | Chrome profile path
|
||||
**EXTEND.md Supports**: Default theme | Default publishing method (api/browser) | Default author | Default open-comment switch | Default fans-only-comment switch | Chrome profile path
|
||||
|
||||
First-time setup: [references/config/first-time-setup.md](references/config/first-time-setup.md)
|
||||
|
||||
**Minimum supported keys** (case-insensitive, accept `1/0` or `true/false`):
|
||||
|
||||
| Key | Default | Mapping |
|
||||
|-----|---------|---------|
|
||||
| `default_author` | empty | Fallback for `author` when CLI/frontmatter not provided |
|
||||
| `need_open_comment` | `1` | `articles[].need_open_comment` in `draft/add` request |
|
||||
| `only_fans_can_comment` | `0` | `articles[].only_fans_can_comment` in `draft/add` request |
|
||||
|
||||
**Recommended EXTEND.md example**:
|
||||
|
||||
```md
|
||||
default_theme: default
|
||||
default_publish_method: api
|
||||
default_author: 宝玉
|
||||
need_open_comment: 1
|
||||
only_fans_can_comment: 0
|
||||
chrome_profile_path: /path/to/chrome/profile
|
||||
```
|
||||
|
||||
**Value priority**:
|
||||
1. CLI arguments
|
||||
2. Frontmatter
|
||||
3. EXTEND.md
|
||||
4. Skill defaults
|
||||
|
||||
## Pre-flight Check (Optional)
|
||||
|
||||
Before first use, suggest running the environment check. User can skip if they prefer.
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/check-permissions.ts
|
||||
```
|
||||
|
||||
Checks: Chrome, profile isolation, Bun, Accessibility, clipboard, paste keystroke, API credentials, Chrome conflicts.
|
||||
|
||||
**If any check fails**, provide fix guidance per item:
|
||||
|
||||
| Check | Fix |
|
||||
|-------|-----|
|
||||
| Chrome | Install Chrome or set `WECHAT_BROWSER_CHROME_PATH` env var |
|
||||
| Profile dir | Ensure `~/.local/share/wechat-browser-profile` is writable |
|
||||
| Bun runtime | `curl -fsSL https://bun.sh/install \| bash` |
|
||||
| Accessibility (macOS) | System Settings → Privacy & Security → Accessibility → enable terminal app |
|
||||
| Clipboard copy | Ensure Swift/AppKit available (macOS Xcode CLI tools: `xcode-select --install`) |
|
||||
| Paste keystroke (macOS) | Same as Accessibility fix above |
|
||||
| Paste keystroke (Linux) | Install `xdotool` (X11) or `ydotool` (Wayland) |
|
||||
| API credentials | Follow guided setup in Step 5, or manually set in `.baoyu-skills/.env` |
|
||||
|
||||
## Image-Text Posting (图文)
|
||||
|
||||
@@ -70,7 +121,7 @@ Publishing Progress:
|
||||
- [ ] Step 1: Determine input type
|
||||
- [ ] Step 2: Check markdown-to-html skill
|
||||
- [ ] Step 3: Convert to HTML
|
||||
- [ ] Step 4: Validate metadata (title, summary)
|
||||
- [ ] Step 4: Validate metadata (title, summary, cover)
|
||||
- [ ] Step 5: Select method and configure credentials
|
||||
- [ ] Step 6: Publish to WeChat
|
||||
- [ ] Step 7: Report completion
|
||||
@@ -80,6 +131,13 @@ Publishing Progress:
|
||||
|
||||
Check and load EXTEND.md settings (see Preferences section above).
|
||||
|
||||
**CRITICAL**: If not found, complete first-time setup BEFORE any other steps or questions.
|
||||
|
||||
Resolve and store these defaults for later steps:
|
||||
- `default_author`
|
||||
- `need_open_comment` (default `1`)
|
||||
- `only_fans_can_comment` (default `0`)
|
||||
|
||||
### Step 1: Determine Input Type
|
||||
|
||||
| Input Type | Detection | Action |
|
||||
@@ -138,20 +196,19 @@ B) Continue - provide HTML file manually
|
||||
|
||||
**Skip if**: Input is `.html` file
|
||||
|
||||
1. **Ask theme preference** (unless specified in EXTEND.md or CLI):
|
||||
1. **Resolve theme** (first match wins, do NOT ask user if resolved):
|
||||
- CLI `--theme` argument
|
||||
- EXTEND.md `default_theme` (loaded in Step 0)
|
||||
- Fallback: `default`
|
||||
|
||||
| Theme | Description |
|
||||
|-------|-------------|
|
||||
| `default` | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
|
||||
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 |
|
||||
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 |
|
||||
|
||||
2. **Execute conversion** (using the discovered skill):
|
||||
2. **Execute conversion** (using the discovered skill), **always pass `--theme`**:
|
||||
|
||||
```bash
|
||||
npx -y bun ${MD_TO_HTML_SKILL_DIR}/scripts/main.ts <markdown_file> --theme <theme>
|
||||
```
|
||||
|
||||
**CRITICAL**: Always include `--theme` parameter. Never omit it, even if using `default`.
|
||||
|
||||
3. **Parse JSON output** to get: `htmlPath`, `title`, `author`, `summary`, `contentImages`
|
||||
|
||||
### Step 4: Validate Metadata
|
||||
@@ -162,11 +219,19 @@ Check extracted metadata from Step 3 (or HTML meta tags if direct HTML input).
|
||||
|-------|------------|
|
||||
| Title | Prompt: "Enter title, or press Enter to auto-generate from content" |
|
||||
| Summary | Prompt: "Enter summary, or press Enter to auto-generate (recommended for SEO)" |
|
||||
| Author | Use fallback chain: CLI `--author` → frontmatter `author` → EXTEND.md `default_author` |
|
||||
|
||||
**Auto-Generation Logic**:
|
||||
- **Title**: First H1/H2 heading, or first sentence
|
||||
- **Summary**: First paragraph, truncated to 120 characters
|
||||
|
||||
**Cover Image Check** (required for `article_type=news`):
|
||||
1. Use CLI `--cover` if provided.
|
||||
2. Else use frontmatter (`coverImage`, `featureImage`, `cover`, `image`).
|
||||
3. Else check article directory default path: `imgs/cover.png`.
|
||||
4. Else fallback to first inline content image.
|
||||
5. If still missing, stop and request a cover image before publishing.
|
||||
|
||||
### Step 5: Select Publishing Method and Configure
|
||||
|
||||
**Ask publishing method** (unless specified in EXTEND.md or CLI):
|
||||
@@ -213,9 +278,20 @@ WECHAT_APP_SECRET=<user_input>
|
||||
**API method**:
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/wechat-api.ts <html_file> [--title <title>] [--summary <summary>]
|
||||
npx -y bun ${SKILL_DIR}/scripts/wechat-api.ts <html_file> [--title <title>] [--summary <summary>] [--author <author>] [--cover <cover_path>]
|
||||
```
|
||||
|
||||
**`draft/add` payload rules**:
|
||||
- Use endpoint: `POST https://api.weixin.qq.com/cgi-bin/draft/add?access_token=ACCESS_TOKEN`
|
||||
- `article_type`: `news` (default) or `newspic`
|
||||
- For `news`, include `thumb_media_id` (cover is required)
|
||||
- Always resolve and send:
|
||||
- `need_open_comment` (default `1`)
|
||||
- `only_fans_can_comment` (default `0`)
|
||||
- `author` resolution: CLI `--author` → frontmatter `author` → EXTEND.md `default_author`
|
||||
|
||||
If script parameters do not expose the two comment fields, still ensure final API request body includes resolved values.
|
||||
|
||||
**Browser method**:
|
||||
|
||||
```bash
|
||||
@@ -237,6 +313,7 @@ Article:
|
||||
• Title: [title]
|
||||
• Summary: [summary]
|
||||
• Images: [N] inline images
|
||||
• Comments: [open/closed], [fans-only/all users]
|
||||
|
||||
Result:
|
||||
✓ Draft saved to WeChat Official Account
|
||||
@@ -289,6 +366,8 @@ Files created:
|
||||
| Multiple images | ✓ (up to 9) | ✓ (inline) | ✓ (inline) |
|
||||
| Themes | ✗ | ✓ | ✓ |
|
||||
| Auto-generate metadata | ✗ | ✓ | ✓ |
|
||||
| Default cover fallback (`imgs/cover.png`) | ✗ | ✓ | ✗ |
|
||||
| Comment control (`need_open_comment`, `only_fans_can_comment`) | ✗ | ✓ | ✗ |
|
||||
| Requires Chrome | ✓ | ✗ | ✓ |
|
||||
| Requires API credentials | ✗ | ✓ | ✗ |
|
||||
| Speed | Medium | Fast | Slow |
|
||||
@@ -322,6 +401,8 @@ Files created:
|
||||
| Not logged in (browser) | First run opens browser - scan QR to log in |
|
||||
| Chrome not found | Set `WECHAT_BROWSER_CHROME_PATH` env var |
|
||||
| Title/summary missing | Use auto-generation or provide manually |
|
||||
| No cover image | Add frontmatter cover or place `imgs/cover.png` in article directory |
|
||||
| Wrong comment defaults | Check `EXTEND.md` keys `need_open_comment` and `only_fans_can_comment` |
|
||||
| Paste fails | Check system clipboard permissions |
|
||||
|
||||
## Extension Support
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
name: first-time-setup
|
||||
description: First-time setup flow for baoyu-post-to-wechat preferences
|
||||
---
|
||||
|
||||
# First-Time Setup
|
||||
|
||||
## Overview
|
||||
|
||||
When no EXTEND.md is found, guide user through preference setup.
|
||||
|
||||
**BLOCKING OPERATION**: This setup MUST complete before ANY other workflow steps. Do NOT:
|
||||
- Ask about content or files to publish
|
||||
- Ask about themes or publishing methods
|
||||
- Proceed to content conversion or publishing
|
||||
|
||||
ONLY ask the questions in this setup flow, save EXTEND.md, then continue.
|
||||
|
||||
## Setup Flow
|
||||
|
||||
```
|
||||
No EXTEND.md found
|
||||
|
|
||||
v
|
||||
+---------------------+
|
||||
| AskUserQuestion |
|
||||
| (all questions) |
|
||||
+---------------------+
|
||||
|
|
||||
v
|
||||
+---------------------+
|
||||
| Create EXTEND.md |
|
||||
+---------------------+
|
||||
|
|
||||
v
|
||||
Continue to Step 1
|
||||
```
|
||||
|
||||
## Questions
|
||||
|
||||
**Language**: Use user's input language or saved language preference.
|
||||
|
||||
Use AskUserQuestion with ALL questions in ONE call:
|
||||
|
||||
### Question 1: Default Theme
|
||||
|
||||
```yaml
|
||||
header: "Theme"
|
||||
question: "Default theme for article conversion?"
|
||||
options:
|
||||
- label: "default (Recommended)"
|
||||
description: "Classic layout - centered title with border, white-on-color H2"
|
||||
- label: "grace"
|
||||
description: "Elegant - text shadows, rounded cards, refined blockquotes"
|
||||
- label: "simple"
|
||||
description: "Minimal modern - asymmetric rounded corners, clean whitespace"
|
||||
```
|
||||
|
||||
### Question 2: Default Publishing Method
|
||||
|
||||
```yaml
|
||||
header: "Method"
|
||||
question: "Default publishing method?"
|
||||
options:
|
||||
- label: "api (Recommended)"
|
||||
description: "Fast, requires API credentials (AppID + AppSecret)"
|
||||
- label: "browser"
|
||||
description: "Slow, requires Chrome and login session"
|
||||
```
|
||||
|
||||
### Question 3: Default Author
|
||||
|
||||
```yaml
|
||||
header: "Author"
|
||||
question: "Default author name for articles?"
|
||||
options:
|
||||
- label: "No default"
|
||||
description: "Leave empty, specify per article"
|
||||
```
|
||||
|
||||
Note: User will likely choose "Other" to type their author name.
|
||||
|
||||
### Question 4: Open Comments
|
||||
|
||||
```yaml
|
||||
header: "Comments"
|
||||
question: "Enable comments on articles by default?"
|
||||
options:
|
||||
- label: "Yes (Recommended)"
|
||||
description: "Allow readers to comment on articles"
|
||||
- label: "No"
|
||||
description: "Disable comments by default"
|
||||
```
|
||||
|
||||
### Question 5: Fans-Only Comments
|
||||
|
||||
```yaml
|
||||
header: "Fans only"
|
||||
question: "Restrict comments to followers only?"
|
||||
options:
|
||||
- label: "No (Recommended)"
|
||||
description: "All readers can comment"
|
||||
- label: "Yes"
|
||||
description: "Only followers can comment"
|
||||
```
|
||||
|
||||
### Question 6: Save Location
|
||||
|
||||
```yaml
|
||||
header: "Save"
|
||||
question: "Where to save preferences?"
|
||||
options:
|
||||
- label: "Project (Recommended)"
|
||||
description: ".baoyu-skills/ (this project only)"
|
||||
- label: "User"
|
||||
description: "~/.baoyu-skills/ (all projects)"
|
||||
```
|
||||
|
||||
## Save Locations
|
||||
|
||||
| Choice | Path | Scope |
|
||||
|--------|------|-------|
|
||||
| Project | `.baoyu-skills/baoyu-post-to-wechat/EXTEND.md` | Current project |
|
||||
| User | `~/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md` | All projects |
|
||||
|
||||
## After Setup
|
||||
|
||||
1. Create directory if needed
|
||||
2. Write EXTEND.md
|
||||
3. Confirm: "Preferences saved to [path]"
|
||||
4. Continue to Step 0 (load the saved preferences)
|
||||
|
||||
## EXTEND.md Template
|
||||
|
||||
```md
|
||||
default_theme: [default/grace/simple]
|
||||
default_publish_method: [api/browser]
|
||||
default_author: [author name or empty]
|
||||
need_open_comment: [1/0]
|
||||
only_fans_can_comment: [1/0]
|
||||
chrome_profile_path:
|
||||
```
|
||||
|
||||
## Modifying Preferences Later
|
||||
|
||||
Users can edit EXTEND.md directly or delete it to trigger setup again.
|
||||
@@ -1,7 +1,9 @@
|
||||
# Image-Text Posting (图文发表)
|
||||
# Image-Text Posting (贴图发表, formerly 图文)
|
||||
|
||||
Post image-text messages with multiple images to WeChat Official Account.
|
||||
|
||||
> **Note**: WeChat has renamed "图文" to "贴图" in the Official Account menu (as of 2026).
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { findChromeExecutable, getDefaultProfileDir } from './cdp.ts';
|
||||
|
||||
interface CheckResult {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
const results: CheckResult[] = [];
|
||||
|
||||
function log(label: string, ok: boolean, detail: string): void {
|
||||
results.push({ name: label, ok, detail });
|
||||
const icon = ok ? '✅' : '❌';
|
||||
console.log(`${icon} ${label}: ${detail}`);
|
||||
}
|
||||
|
||||
function warn(label: string, detail: string): void {
|
||||
results.push({ name: label, ok: true, detail });
|
||||
console.log(`⚠️ ${label}: ${detail}`);
|
||||
}
|
||||
|
||||
async function checkChrome(): Promise<void> {
|
||||
const chromePath = findChromeExecutable();
|
||||
if (chromePath) {
|
||||
log('Chrome', true, chromePath);
|
||||
} else {
|
||||
log('Chrome', false, 'Not found. Set WECHAT_BROWSER_CHROME_PATH env var or install Chrome.');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkProfileIsolation(): Promise<void> {
|
||||
const profileDir = getDefaultProfileDir();
|
||||
const userChromeDir = process.platform === 'darwin'
|
||||
? path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome')
|
||||
: process.platform === 'win32'
|
||||
? path.join(os.homedir(), 'AppData', 'Local', 'Google', 'Chrome', 'User Data')
|
||||
: path.join(os.homedir(), '.config', 'google-chrome');
|
||||
|
||||
const isIsolated = !profileDir.startsWith(userChromeDir);
|
||||
log('Profile isolation', isIsolated, `Skill profile: ${profileDir}`);
|
||||
|
||||
if (isIsolated) {
|
||||
const exists = fs.existsSync(profileDir);
|
||||
if (exists) {
|
||||
log('Profile dir', true, 'Exists and accessible');
|
||||
} else {
|
||||
try {
|
||||
fs.mkdirSync(profileDir, { recursive: true });
|
||||
log('Profile dir', true, 'Created successfully');
|
||||
} catch (e) {
|
||||
log('Profile dir', false, `Cannot create: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAccessibility(): Promise<void> {
|
||||
if (process.platform !== 'darwin') {
|
||||
log('Accessibility', true, `Skipped (not macOS, platform: ${process.platform})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = spawnSync('osascript', ['-e', `
|
||||
tell application "System Events"
|
||||
set frontApp to name of first application process whose frontmost is true
|
||||
return frontApp
|
||||
end tell
|
||||
`], { stdio: 'pipe', timeout: 10_000 });
|
||||
|
||||
if (result.status === 0) {
|
||||
const app = result.stdout?.toString().trim();
|
||||
log('Accessibility (System Events)', true, `Frontmost app: ${app}`);
|
||||
} else {
|
||||
const stderr = result.stderr?.toString().trim() || '';
|
||||
if (stderr.includes('not allowed assistive access') || stderr.includes('1002')) {
|
||||
log('Accessibility (System Events)', false,
|
||||
'Denied. Grant access: System Settings → Privacy & Security → Accessibility → enable your terminal app');
|
||||
} else {
|
||||
log('Accessibility (System Events)', false, `Failed: ${stderr}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkClipboardCopy(): Promise<void> {
|
||||
if (process.platform !== 'darwin') {
|
||||
log('Clipboard copy (image)', true, `Skipped (not macOS)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'wechat-check-'));
|
||||
try {
|
||||
const testPng = path.join(tmpDir, 'test.png');
|
||||
const swiftSrc = `import AppKit
|
||||
import Foundation
|
||||
let size = NSSize(width: 2, height: 2)
|
||||
let image = NSImage(size: size)
|
||||
image.lockFocus()
|
||||
NSColor.red.set()
|
||||
NSBezierPath.fill(NSRect(origin: .zero, size: size))
|
||||
image.unlockFocus()
|
||||
guard let tiff = image.tiffRepresentation,
|
||||
let rep = NSBitmapImageRep(data: tiff),
|
||||
let png = rep.representation(using: .png, properties: [:]) else {
|
||||
FileHandle.standardError.write("Failed to create test PNG\\n".data(using: .utf8)!)
|
||||
exit(1)
|
||||
}
|
||||
try png.write(to: URL(fileURLWithPath: CommandLine.arguments[1]))
|
||||
`;
|
||||
const genScript = path.join(tmpDir, 'gen.swift');
|
||||
await writeFile(genScript, swiftSrc, 'utf8');
|
||||
const genResult = spawnSync('swift', [genScript, testPng], { stdio: 'pipe', timeout: 30_000 });
|
||||
if (genResult.status !== 0) {
|
||||
log('Clipboard copy (image)', false, `Cannot create test image: ${genResult.stderr?.toString().trim()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const clipSrc = `import AppKit
|
||||
import Foundation
|
||||
guard let image = NSImage(contentsOfFile: CommandLine.arguments[1]) else {
|
||||
FileHandle.standardError.write("Failed to load image\\n".data(using: .utf8)!)
|
||||
exit(1)
|
||||
}
|
||||
let pb = NSPasteboard.general
|
||||
pb.clearContents()
|
||||
if !pb.writeObjects([image]) {
|
||||
FileHandle.standardError.write("Failed to write to clipboard\\n".data(using: .utf8)!)
|
||||
exit(1)
|
||||
}
|
||||
`;
|
||||
const clipScript = path.join(tmpDir, 'clip.swift');
|
||||
await writeFile(clipScript, clipSrc, 'utf8');
|
||||
const clipResult = spawnSync('swift', [clipScript, testPng], { stdio: 'pipe', timeout: 30_000 });
|
||||
if (clipResult.status === 0) {
|
||||
log('Clipboard copy (image)', true, 'Can copy image to clipboard via Swift/AppKit');
|
||||
} else {
|
||||
log('Clipboard copy (image)', false, `Failed: ${clipResult.stderr?.toString().trim()}`);
|
||||
}
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function checkPasteKeystroke(): Promise<void> {
|
||||
if (process.platform === 'darwin') {
|
||||
const result = spawnSync('osascript', ['-e', `
|
||||
tell application "System Events"
|
||||
set canSend to true
|
||||
return canSend
|
||||
end tell
|
||||
`], { stdio: 'pipe', timeout: 10_000 });
|
||||
|
||||
if (result.status === 0) {
|
||||
log('Paste keystroke (osascript)', true, 'System Events can send keystrokes');
|
||||
} else {
|
||||
const stderr = result.stderr?.toString().trim() || '';
|
||||
log('Paste keystroke (osascript)', false, `Cannot send keystrokes: ${stderr}`);
|
||||
}
|
||||
} else if (process.platform === 'linux') {
|
||||
const xdotool = spawnSync('which', ['xdotool'], { stdio: 'pipe' });
|
||||
const ydotool = spawnSync('which', ['ydotool'], { stdio: 'pipe' });
|
||||
if (xdotool.status === 0) {
|
||||
log('Paste keystroke', true, 'xdotool available (X11)');
|
||||
} else if (ydotool.status === 0) {
|
||||
log('Paste keystroke', true, 'ydotool available (Wayland)');
|
||||
} else {
|
||||
log('Paste keystroke', false, 'No tool found. Install xdotool (X11) or ydotool (Wayland).');
|
||||
}
|
||||
} else if (process.platform === 'win32') {
|
||||
log('Paste keystroke', true, 'Windows uses PowerShell SendKeys (built-in)');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkBun(): Promise<void> {
|
||||
const result = spawnSync('npx', ['-y', 'bun', '--version'], { stdio: 'pipe', timeout: 30_000 });
|
||||
if (result.status === 0) {
|
||||
log('Bun runtime', true, `v${result.stdout?.toString().trim()}`);
|
||||
} else {
|
||||
log('Bun runtime', false, 'Cannot run bun. Install: curl -fsSL https://bun.sh/install | bash');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkApiCredentials(): Promise<void> {
|
||||
const cwd = process.cwd();
|
||||
const projectEnv = path.join(cwd, '.baoyu-skills', '.env');
|
||||
const userEnv = path.join(os.homedir(), '.baoyu-skills', '.env');
|
||||
|
||||
let found = false;
|
||||
for (const envPath of [projectEnv, userEnv]) {
|
||||
if (fs.existsSync(envPath)) {
|
||||
const content = fs.readFileSync(envPath, 'utf8');
|
||||
if (content.includes('WECHAT_APP_ID')) {
|
||||
log('API credentials', true, `Found in ${envPath}`);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
warn('API credentials', 'Not found. Required for API publishing method. Run the skill to set up via guided flow.');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkRunningChromeConflict(): Promise<void> {
|
||||
if (process.platform !== 'darwin') return;
|
||||
|
||||
const result = spawnSync('pgrep', ['-f', 'Google Chrome'], { stdio: 'pipe' });
|
||||
const pids = result.stdout?.toString().trim().split('\n').filter(Boolean) || [];
|
||||
|
||||
if (pids.length > 0) {
|
||||
warn('Running Chrome instances', `${pids.length} Chrome process(es) detected. The skill uses --user-data-dir for isolation, so this is safe.`);
|
||||
} else {
|
||||
log('Running Chrome instances', true, 'No existing Chrome processes');
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log('=== baoyu-post-to-wechat: Permission & Environment Check ===\n');
|
||||
|
||||
await checkChrome();
|
||||
await checkProfileIsolation();
|
||||
await checkBun();
|
||||
await checkAccessibility();
|
||||
await checkClipboardCopy();
|
||||
await checkPasteKeystroke();
|
||||
await checkApiCredentials();
|
||||
await checkRunningChromeConflict();
|
||||
|
||||
console.log('\n--- Summary ---');
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
if (failed.length === 0) {
|
||||
console.log('All checks passed. Ready to post to WeChat.');
|
||||
} else {
|
||||
console.log(`${failed.length} issue(s) found:`);
|
||||
for (const f of failed) {
|
||||
console.log(` ❌ ${f.name}: ${f.detail}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
await main().catch((err) => {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -228,25 +228,25 @@ async function copyHtmlLinux(htmlFilePath: string): Promise<void> {
|
||||
}
|
||||
|
||||
async function copyImageWindows(imagePath: string): Promise<void> {
|
||||
const escaped = imagePath.replace(/'/g, "''");
|
||||
const ps = [
|
||||
'param([string]$Path)',
|
||||
'Add-Type -AssemblyName System.Windows.Forms',
|
||||
'Add-Type -AssemblyName System.Drawing',
|
||||
'$img = [System.Drawing.Image]::FromFile($Path)',
|
||||
`$img = [System.Drawing.Image]::FromFile('${escaped}')`,
|
||||
'[System.Windows.Forms.Clipboard]::SetImage($img)',
|
||||
'$img.Dispose()',
|
||||
].join('; ');
|
||||
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps, '-Path', imagePath]);
|
||||
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps]);
|
||||
}
|
||||
|
||||
async function copyHtmlWindows(htmlFilePath: string): Promise<void> {
|
||||
const escaped = htmlFilePath.replace(/'/g, "''");
|
||||
const ps = [
|
||||
'param([string]$Path)',
|
||||
'Add-Type -AssemblyName System.Windows.Forms',
|
||||
'$html = Get-Content -Raw -LiteralPath $Path',
|
||||
`$html = Get-Content -Raw -LiteralPath '${escaped}'`,
|
||||
'[System.Windows.Forms.Clipboard]::SetText($html, [System.Windows.Forms.TextDataFormat]::Html)',
|
||||
].join('; ');
|
||||
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps, '-Path', htmlFilePath]);
|
||||
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps]);
|
||||
}
|
||||
|
||||
async function copyImageToClipboard(imagePathInput: string): Promise<void> {
|
||||
|
||||
@@ -120,22 +120,28 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
|
||||
const { frontmatter, body } = parseFrontmatter(content);
|
||||
|
||||
let title = options?.title ?? frontmatter.title ?? '';
|
||||
let bodyWithoutTitle = body;
|
||||
if (!title) {
|
||||
const lines = body.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const headingMatch = trimmed.match(/^#{1,2}\s+(.+)$/);
|
||||
if (headingMatch) title = headingMatch[1]!;
|
||||
if (headingMatch) {
|
||||
title = headingMatch[1]!;
|
||||
bodyWithoutTitle = body.replace(/^#{1,2}\s+.+\r?\n?/, '');
|
||||
}
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
bodyWithoutTitle = body.replace(/^#{1,2}\s+.+\r?\n?/, '');
|
||||
}
|
||||
if (!title) title = path.basename(markdownPath, path.extname(markdownPath));
|
||||
const author = frontmatter.author || '';
|
||||
let summary = frontmatter.description || frontmatter.summary || '';
|
||||
|
||||
if (!summary) {
|
||||
const lines = body.split('\n');
|
||||
const lines = bodyWithoutTitle.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
@@ -161,7 +167,7 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
|
||||
const images: Array<{ src: string; placeholder: string }> = [];
|
||||
let imageCounter = 0;
|
||||
|
||||
const modifiedBody = body.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
|
||||
const modifiedBody = bodyWithoutTitle.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
|
||||
const placeholder = `WECHATIMGPH_${++imageCounter}`;
|
||||
images.push({ src, placeholder });
|
||||
return placeholder;
|
||||
|
||||
@@ -374,7 +374,7 @@ export function initRenderer(opts: IOpts = {}): RendererAPI {
|
||||
|
||||
listCounters[listCounters.length - 1] = idx + 1;
|
||||
|
||||
const prefix = ordered ? `${idx}. ` : "• ";
|
||||
const prefix = ordered ? "" : "• ";
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
|
||||
@@ -365,7 +365,7 @@ Frontmatter Fields (markdown):
|
||||
title Article title
|
||||
author Author name
|
||||
digest/summary Article summary
|
||||
featureImage/coverImage/cover/image Cover image path
|
||||
coverImage/featureImage/cover/image Cover image path
|
||||
|
||||
Comments:
|
||||
Comments are enabled by default, open to all users.
|
||||
@@ -517,6 +517,13 @@ async function main(): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (digest && digest.length > 120) {
|
||||
const truncated = digest.slice(0, 117);
|
||||
const lastPunct = Math.max(truncated.lastIndexOf("。"), truncated.lastIndexOf(","), truncated.lastIndexOf(";"), truncated.lastIndexOf("、"));
|
||||
digest = lastPunct > 80 ? truncated.slice(0, lastPunct + 1) : truncated + "...";
|
||||
console.error(`[wechat-api] Digest truncated to ${digest.length} chars`);
|
||||
}
|
||||
|
||||
console.error(`[wechat-api] Title: ${title}`);
|
||||
if (author) console.error(`[wechat-api] Author: ${author}`);
|
||||
if (digest) console.error(`[wechat-api] Digest: ${digest.slice(0, 50)}...`);
|
||||
@@ -547,11 +554,14 @@ async function main(): Promise<void> {
|
||||
htmlContent = processedHtml;
|
||||
|
||||
let thumbMediaId = "";
|
||||
const coverPath = args.cover ||
|
||||
frontmatter.featureImage ||
|
||||
const rawCoverPath = args.cover ||
|
||||
frontmatter.coverImage ||
|
||||
frontmatter.featureImage ||
|
||||
frontmatter.cover ||
|
||||
frontmatter.image;
|
||||
const coverPath = rawCoverPath && !path.isAbsolute(rawCoverPath) && args.cover
|
||||
? path.resolve(process.cwd(), rawCoverPath)
|
||||
: rawCoverPath;
|
||||
|
||||
if (coverPath) {
|
||||
console.error(`[wechat-api] Uploading cover: ${coverPath}`);
|
||||
@@ -568,7 +578,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
if (args.articleType === "news" && !thumbMediaId) {
|
||||
console.error("Error: No cover image. Provide via --cover, frontmatter.featureImage, or include an image in content.");
|
||||
console.error("Error: No cover image. Provide via --cover, frontmatter.coverImage, or include an image in content.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -315,6 +315,71 @@ async function pressDeleteKey(session: ChromeSession): Promise<void> {
|
||||
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId: session.sessionId });
|
||||
}
|
||||
|
||||
async function removeExtraEmptyLineAfterImage(session: ChromeSession): Promise<boolean> {
|
||||
const removed = await evaluate<boolean>(session, `
|
||||
(function() {
|
||||
const editor = document.querySelector('.ProseMirror');
|
||||
if (!editor) return false;
|
||||
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return false;
|
||||
|
||||
let node = sel.anchorNode;
|
||||
if (!node) return false;
|
||||
let element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
||||
if (!element || !editor.contains(element)) return false;
|
||||
|
||||
const isEmptyParagraph = (el) => {
|
||||
if (!el || el.tagName !== 'P') return false;
|
||||
const text = (el.textContent || '').trim();
|
||||
if (text.length > 0) return false;
|
||||
return el.querySelectorAll('img, figure, video, iframe').length === 0;
|
||||
};
|
||||
|
||||
const hasImage = (el) => {
|
||||
if (!el) return false;
|
||||
return !!el.querySelector('img, figure img, picture img');
|
||||
};
|
||||
|
||||
const placeCursorAfter = (el) => {
|
||||
if (!el) return;
|
||||
const range = document.createRange();
|
||||
range.setStartAfter(el);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
};
|
||||
|
||||
// Case 1: caret is inside an empty paragraph right after an image block.
|
||||
const emptyPara = element.closest('p');
|
||||
if (emptyPara && editor.contains(emptyPara) && isEmptyParagraph(emptyPara)) {
|
||||
const prev = emptyPara.previousElementSibling;
|
||||
if (prev && hasImage(prev)) {
|
||||
emptyPara.remove();
|
||||
placeCursorAfter(prev);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: caret is on the image block itself; remove the next empty paragraph.
|
||||
const imageBlock = element.closest('figure, p');
|
||||
if (imageBlock && editor.contains(imageBlock) && hasImage(imageBlock)) {
|
||||
const next = imageBlock.nextElementSibling;
|
||||
if (next && isEmptyParagraph(next)) {
|
||||
next.remove();
|
||||
placeCursorAfter(imageBlock);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
})()
|
||||
`);
|
||||
|
||||
if (removed) console.log('[wechat] Removed extra empty line after image.');
|
||||
return removed;
|
||||
}
|
||||
|
||||
export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
const { title, content, htmlFile, markdownFile, theme, author, summary, images = [], submit = false, profileDir, cdpPort } = options;
|
||||
let { contentImages = [] } = options;
|
||||
@@ -454,11 +519,6 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
await evaluate(session, `document.querySelector('#author').value = ${JSON.stringify(effectiveAuthor)}; document.querySelector('#author').dispatchEvent(new Event('input', { bubbles: true }));`);
|
||||
}
|
||||
|
||||
if (effectiveSummary) {
|
||||
console.log(`[wechat] Filling summary: ${effectiveSummary}`);
|
||||
await evaluate(session, `document.querySelector('#js_description').value = ${JSON.stringify(effectiveSummary)}; document.querySelector('#js_description').dispatchEvent(new Event('input', { bubbles: true }));`);
|
||||
}
|
||||
|
||||
await sleep(500);
|
||||
|
||||
if (effectiveTitle) {
|
||||
@@ -470,15 +530,6 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveSummary) {
|
||||
const actualSummary = await evaluate<string>(session, `document.querySelector('#js_description')?.value || ''`);
|
||||
if (actualSummary === effectiveSummary) {
|
||||
console.log('[wechat] Summary verified OK.');
|
||||
} else {
|
||||
console.warn(`[wechat] Summary verification failed. Expected: "${effectiveSummary}", got: "${actualSummary}"`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[wechat] Clicking on editor...');
|
||||
await clickElement(session, '.ProseMirror');
|
||||
await sleep(1000);
|
||||
@@ -534,6 +585,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
console.log('[wechat] Pasting image...');
|
||||
await pasteFromClipboardInEditor(session);
|
||||
await sleep(3000);
|
||||
await removeExtraEmptyLineAfterImage(session);
|
||||
}
|
||||
console.log('[wechat] All images inserted.');
|
||||
}
|
||||
@@ -545,6 +597,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
await sleep(500);
|
||||
await pasteInEditor(session);
|
||||
await sleep(2000);
|
||||
await removeExtraEmptyLineAfterImage(session);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,6 +620,30 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveSummary) {
|
||||
console.log(`[wechat] Filling summary (after content paste): ${effectiveSummary}`);
|
||||
await evaluate(session, `
|
||||
(function() {
|
||||
const el = document.querySelector('#js_description');
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.select();
|
||||
el.value = ${JSON.stringify(effectiveSummary)};
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('blur', { bubbles: true }));
|
||||
})()
|
||||
`);
|
||||
await sleep(500);
|
||||
|
||||
const actualSummary = await evaluate<string>(session, `document.querySelector('#js_description')?.value || ''`);
|
||||
if (actualSummary === effectiveSummary) {
|
||||
console.log('[wechat] Summary verified OK.');
|
||||
} else {
|
||||
console.warn(`[wechat] Summary verification failed. Expected: "${effectiveSummary}", got: "${actualSummary}"`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[wechat] Saving as draft...');
|
||||
await evaluate(session, `document.querySelector('#js_submit button').click()`);
|
||||
await sleep(3000);
|
||||
|
||||
@@ -397,7 +397,7 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
|
||||
|
||||
await sleep(2000);
|
||||
|
||||
console.log('[wechat-browser] Looking for "图文" menu...');
|
||||
console.log('[wechat-browser] Looking for "贴图" menu...');
|
||||
const menuResult = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
const menuItems = document.querySelectorAll('.new-creation__menu .new-creation__menu-item');
|
||||
@@ -417,7 +417,7 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
|
||||
const initialIds = new Set(initialTargets.targetInfos.map(t => t.targetId));
|
||||
console.log(`[wechat-browser] Initial targets count: ${initialTargets.targetInfos.length}`);
|
||||
|
||||
console.log('[wechat-browser] Finding "图文" menu position...');
|
||||
console.log('[wechat-browser] Finding "贴图" menu position...');
|
||||
const menuPos = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
(function() {
|
||||
@@ -427,10 +427,10 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
|
||||
const title = item.querySelector('.new-creation__menu-title');
|
||||
const text = title?.textContent?.trim() || '';
|
||||
console.log('Menu item text:', text);
|
||||
if (text === '图文') {
|
||||
if (text === '图文' || text === '贴图') {
|
||||
item.scrollIntoView({ block: 'center' });
|
||||
const rect = item.getBoundingClientRect();
|
||||
console.log('Found 图文,rect:', JSON.stringify(rect));
|
||||
console.log('Found 贴图,rect:', JSON.stringify(rect));
|
||||
return JSON.stringify({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2, width: rect.width, height: rect.height });
|
||||
}
|
||||
}
|
||||
@@ -442,9 +442,9 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
|
||||
console.log(`[wechat-browser] Menu position: ${menuPos.result.value}`);
|
||||
|
||||
const pos = menuPos.result.value !== 'null' ? JSON.parse(menuPos.result.value) : null;
|
||||
if (!pos) throw new Error('图文 menu not found or not visible');
|
||||
if (!pos) throw new Error('贴图 menu not found or not visible');
|
||||
|
||||
console.log('[wechat-browser] Clicking "图文" menu with mouse events...');
|
||||
console.log('[wechat-browser] Clicking "贴图" menu with mouse events...');
|
||||
await cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed',
|
||||
x: pos.x,
|
||||
@@ -533,11 +533,22 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
|
||||
console.log(`[wechat-browser] Images: ${absolutePaths.join(', ')}`);
|
||||
|
||||
const { root } = await cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {}, { sessionId });
|
||||
const { nodeId } = await cdp.send<{ nodeId: number }>('DOM.querySelector', {
|
||||
|
||||
// Try primary selector, then fallback to any multi-file image input
|
||||
let { nodeId } = await cdp.send<{ nodeId: number }>('DOM.querySelector', {
|
||||
nodeId: root.nodeId,
|
||||
selector: '.js_upload_btn_container input[type=file]',
|
||||
}, { sessionId });
|
||||
|
||||
if (!nodeId) {
|
||||
console.log('[wechat-browser] Primary file input not found, trying fallback selector...');
|
||||
const fallback = await cdp.send<{ nodeId: number }>('DOM.querySelector', {
|
||||
nodeId: root.nodeId,
|
||||
selector: 'input[type=file][multiple][accept*="image"]',
|
||||
}, { sessionId });
|
||||
nodeId = fallback.nodeId;
|
||||
}
|
||||
|
||||
if (!nodeId) throw new Error('File input not found');
|
||||
|
||||
await cdp.send('DOM.setFileInputFiles', {
|
||||
@@ -545,7 +556,27 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
|
||||
files: absolutePaths,
|
||||
}, { sessionId });
|
||||
|
||||
await sleep(1000);
|
||||
// Dispatch change event to trigger the upload
|
||||
await cdp.send('Runtime.evaluate', {
|
||||
expression: `
|
||||
const fileInput = document.querySelector('.js_upload_btn_container input[type=file]') || document.querySelector('input[type=file][multiple][accept*="image"]');
|
||||
if (fileInput) fileInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
`,
|
||||
}, { sessionId });
|
||||
|
||||
// Wait for images to upload
|
||||
console.log('[wechat-browser] Waiting for images to upload...');
|
||||
const targetCount = absolutePaths.length;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await sleep(2000);
|
||||
const uploadCheck = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `JSON.stringify({ uploaded: document.querySelectorAll('.weui-desktop-upload__thumb, .pic_item, [class*=upload_thumb]').length })`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
const status = JSON.parse(uploadCheck.result.value);
|
||||
console.log(`[wechat-browser] Upload progress: ${status.uploaded}/${targetCount}`);
|
||||
if (status.uploaded >= targetCount) break;
|
||||
}
|
||||
|
||||
console.log('[wechat-browser] Filling title...');
|
||||
await cdp.send('Runtime.evaluate', {
|
||||
@@ -561,73 +592,134 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
|
||||
}, { sessionId });
|
||||
await sleep(500);
|
||||
|
||||
console.log('[wechat-browser] Clicking on content editor...');
|
||||
const editorPos = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
console.log('[wechat-browser] Filling content...');
|
||||
// Try ProseMirror editor first (new WeChat UI), then fallback to old editor
|
||||
const contentResult = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
(function() {
|
||||
const editor = document.querySelector('.js_pmEditorArea');
|
||||
if (editor) {
|
||||
const rect = editor.getBoundingClientRect();
|
||||
return JSON.stringify({ x: rect.x + 50, y: rect.y + 20 });
|
||||
const contentHtml = ${JSON.stringify('<p>' + content.split('\n').filter(l => l.trim()).join('</p><p>') + '</p>')};
|
||||
|
||||
// New UI: ProseMirror contenteditable
|
||||
const pm = document.querySelector('.ProseMirror[contenteditable=true]');
|
||||
if (pm) {
|
||||
pm.innerHTML = contentHtml;
|
||||
pm.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
return 'ProseMirror: content set, length=' + pm.textContent.length;
|
||||
}
|
||||
return 'null';
|
||||
|
||||
// Old UI: .js_pmEditorArea
|
||||
const oldEditor = document.querySelector('.js_pmEditorArea');
|
||||
if (oldEditor) {
|
||||
return JSON.stringify({ type: 'old', x: oldEditor.getBoundingClientRect().x + 50, y: oldEditor.getBoundingClientRect().y + 20 });
|
||||
}
|
||||
|
||||
return 'editor_not_found';
|
||||
})()
|
||||
`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
|
||||
if (editorPos.result.value === 'null') throw new Error('Content editor not found');
|
||||
const editorClickPos = JSON.parse(editorPos.result.value);
|
||||
const contentStatus = contentResult.result.value;
|
||||
console.log(`[wechat-browser] Content result: ${contentStatus}`);
|
||||
|
||||
await cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed',
|
||||
x: editorClickPos.x,
|
||||
y: editorClickPos.y,
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
}, { sessionId });
|
||||
await sleep(50);
|
||||
await cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mouseReleased',
|
||||
x: editorClickPos.x,
|
||||
y: editorClickPos.y,
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
}, { sessionId });
|
||||
await sleep(300);
|
||||
|
||||
console.log('[wechat-browser] Typing content with keyboard simulation...');
|
||||
const lines = content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.length > 0) {
|
||||
await cdp.send('Input.insertText', { text: line }, { sessionId });
|
||||
}
|
||||
if (i < lines.length - 1) {
|
||||
await cdp.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyDown',
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
windowsVirtualKeyCode: 13,
|
||||
}, { sessionId });
|
||||
await cdp.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyUp',
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
windowsVirtualKeyCode: 13,
|
||||
}, { sessionId });
|
||||
}
|
||||
await sleep(50);
|
||||
if (contentStatus === 'editor_not_found') {
|
||||
throw new Error('Content editor not found');
|
||||
}
|
||||
|
||||
// Fallback: old editor uses keyboard simulation
|
||||
if (contentStatus.startsWith('{')) {
|
||||
const editorClickPos = JSON.parse(contentStatus);
|
||||
if (editorClickPos.type === 'old') {
|
||||
console.log('[wechat-browser] Using old editor with keyboard simulation...');
|
||||
await cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed',
|
||||
x: editorClickPos.x,
|
||||
y: editorClickPos.y,
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
}, { sessionId });
|
||||
await sleep(50);
|
||||
await cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mouseReleased',
|
||||
x: editorClickPos.x,
|
||||
y: editorClickPos.y,
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
}, { sessionId });
|
||||
await sleep(300);
|
||||
|
||||
const lines = content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line!.length > 0) {
|
||||
await cdp.send('Input.insertText', { text: line }, { sessionId });
|
||||
}
|
||||
if (i < lines.length - 1) {
|
||||
await cdp.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyDown',
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
windowsVirtualKeyCode: 13,
|
||||
}, { sessionId });
|
||||
await cdp.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyUp',
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
windowsVirtualKeyCode: 13,
|
||||
}, { sessionId });
|
||||
}
|
||||
await sleep(50);
|
||||
}
|
||||
console.log('[wechat-browser] Content typed via keyboard.');
|
||||
}
|
||||
}
|
||||
console.log('[wechat-browser] Content typed.');
|
||||
await sleep(500);
|
||||
|
||||
if (submit) {
|
||||
console.log('[wechat-browser] Saving as draft...');
|
||||
await cdp.send('Runtime.evaluate', {
|
||||
expression: `document.querySelector('#js_submit')?.click()`,
|
||||
const submitResult = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
(function() {
|
||||
// Try new UI: find button by text
|
||||
const allBtns = document.querySelectorAll('button');
|
||||
for (const btn of allBtns) {
|
||||
const text = btn.textContent?.trim();
|
||||
if (text === '保存为草稿') {
|
||||
btn.click();
|
||||
return 'clicked:保存为草稿';
|
||||
}
|
||||
}
|
||||
// Fallback: old UI selector
|
||||
const oldBtn = document.querySelector('#js_submit');
|
||||
if (oldBtn) {
|
||||
oldBtn.click();
|
||||
return 'clicked:#js_submit';
|
||||
}
|
||||
// List available buttons for debugging
|
||||
const btnTexts = [];
|
||||
allBtns.forEach(b => {
|
||||
const t = b.textContent?.trim();
|
||||
if (t && t.length < 20) btnTexts.push(t);
|
||||
});
|
||||
return 'not_found:' + btnTexts.join(',');
|
||||
})()
|
||||
`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
console.log(`[wechat-browser] Submit result: ${submitResult.result.value}`);
|
||||
await sleep(3000);
|
||||
|
||||
// Verify save success by checking for toast
|
||||
const toastCheck = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
const toasts = document.querySelectorAll('.weui-desktop-toast, [class*=toast]');
|
||||
const msgs = [];
|
||||
toasts.forEach(t => { const text = t.textContent?.trim(); if (text) msgs.push(text); });
|
||||
JSON.stringify(msgs);
|
||||
`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
console.log(`[wechat-browser] Toast messages: ${toastCheck.result.value}`);
|
||||
console.log('[wechat-browser] Draft saved!');
|
||||
} else {
|
||||
console.log('[wechat-browser] Article composed (preview mode). Add --submit to save as draft.');
|
||||
@@ -641,7 +733,7 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
|
||||
}
|
||||
|
||||
function printUsage(): never {
|
||||
console.log(`Post image-text (图文) to WeChat Official Account
|
||||
console.log(`Post image-text (贴图) to WeChat Official Account
|
||||
|
||||
Usage:
|
||||
npx -y bun wechat-browser.ts [options]
|
||||
|
||||
@@ -26,6 +26,7 @@ Posts text, images, videos, and long-form articles to X via real Chrome browser
|
||||
| `scripts/md-to-html.ts` | Markdown → HTML conversion |
|
||||
| `scripts/copy-to-clipboard.ts` | Copy content to clipboard |
|
||||
| `scripts/paste-from-clipboard.ts` | Send real paste keystroke |
|
||||
| `scripts/check-paste-permissions.ts` | Verify environment & permissions |
|
||||
|
||||
## Preferences (EXTEND.md)
|
||||
|
||||
@@ -55,7 +56,7 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md" && echo "user"
|
||||
│ Not found │ Use defaults │
|
||||
└───────────┴───────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
**EXTEND.md Supports**: Default Chrome profile | Auto-submit preference
|
||||
**EXTEND.md Supports**: Default Chrome profile
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -63,6 +64,28 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md" && echo "user"
|
||||
- `bun` runtime
|
||||
- First run: log in to X manually (session saved)
|
||||
|
||||
## Pre-flight Check (Optional)
|
||||
|
||||
Before first use, suggest running the environment check. User can skip if they prefer.
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/check-paste-permissions.ts
|
||||
```
|
||||
|
||||
Checks: Chrome, profile isolation, Bun, Accessibility, clipboard, paste keystroke, Chrome conflicts.
|
||||
|
||||
**If any check fails**, provide fix guidance per item:
|
||||
|
||||
| Check | Fix |
|
||||
|-------|-----|
|
||||
| Chrome | Install Chrome or set `X_BROWSER_CHROME_PATH` env var |
|
||||
| Profile dir | Ensure `~/.local/share/x-browser-profile` is writable |
|
||||
| Bun runtime | `curl -fsSL https://bun.sh/install \| bash` |
|
||||
| Accessibility (macOS) | System Settings → Privacy & Security → Accessibility → enable terminal app |
|
||||
| Clipboard copy | Ensure Swift/AppKit available (macOS Xcode CLI tools: `xcode-select --install`) |
|
||||
| Paste keystroke (macOS) | Same as Accessibility fix above |
|
||||
| Paste keystroke (Linux) | Install `xdotool` (X11) or `ydotool` (Wayland) |
|
||||
|
||||
## References
|
||||
|
||||
- **Regular Posts**: See `references/regular-posts.md` for manual workflow, troubleshooting, and technical details
|
||||
@@ -75,8 +98,7 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md" && echo "user"
|
||||
Text + up to 4 images.
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png # Preview
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png --submit # Post
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
@@ -84,9 +106,10 @@ npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png --subm
|
||||
|-----------|-------------|
|
||||
| `<text>` | Post content (positional) |
|
||||
| `--image <path>` | Image file (repeatable, max 4) |
|
||||
| `--submit` | Post (default: preview) |
|
||||
| `--profile <dir>` | Custom Chrome profile |
|
||||
|
||||
**Note**: Script opens browser with content filled in. User reviews and publishes manually.
|
||||
|
||||
---
|
||||
|
||||
## Video Posts
|
||||
@@ -94,8 +117,7 @@ npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png --subm
|
||||
Text + video file.
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Check this out!" --video ./clip.mp4 # Preview
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Amazing content" --video ./demo.mp4 --submit # Post
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Check this out!" --video ./clip.mp4
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
@@ -103,9 +125,10 @@ npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Amazing content" --video ./demo.mp4
|
||||
|-----------|-------------|
|
||||
| `<text>` | Post content (positional) |
|
||||
| `--video <path>` | Video file (MP4, MOV, WebM) |
|
||||
| `--submit` | Post (default: preview) |
|
||||
| `--profile <dir>` | Custom Chrome profile |
|
||||
|
||||
**Note**: Script opens browser with content filled in. User reviews and publishes manually.
|
||||
|
||||
**Limits**: Regular 140s max, Premium 60min. Processing: 30-60s.
|
||||
|
||||
---
|
||||
@@ -115,8 +138,7 @@ npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Amazing content" --video ./demo.mp4
|
||||
Quote an existing tweet with comment.
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-quote.ts https://x.com/user/status/123 "Great insight!" # Preview
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-quote.ts https://x.com/user/status/123 "I agree!" --submit # Post
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-quote.ts https://x.com/user/status/123 "Great insight!"
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
@@ -124,9 +146,10 @@ npx -y bun ${SKILL_DIR}/scripts/x-quote.ts https://x.com/user/status/123 "I agre
|
||||
|-----------|-------------|
|
||||
| `<tweet-url>` | URL to quote (positional) |
|
||||
| `<comment>` | Comment text (positional, optional) |
|
||||
| `--submit` | Post (default: preview) |
|
||||
| `--profile <dir>` | Custom Chrome profile |
|
||||
|
||||
**Note**: Script opens browser with content filled in. User reviews and publishes manually.
|
||||
|
||||
---
|
||||
|
||||
## X Articles
|
||||
@@ -134,9 +157,8 @@ npx -y bun ${SKILL_DIR}/scripts/x-quote.ts https://x.com/user/status/123 "I agre
|
||||
Long-form Markdown articles (requires X Premium).
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md # Preview
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --cover ./cover.jpg # With cover
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --submit # Publish
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --cover ./cover.jpg
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
@@ -145,16 +167,29 @@ npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --submit #
|
||||
| `<markdown>` | Markdown file (positional) |
|
||||
| `--cover <path>` | Cover image |
|
||||
| `--title <text>` | Override title |
|
||||
| `--submit` | Publish (default: preview) |
|
||||
|
||||
**Frontmatter**: `title`, `cover_image` supported in YAML front matter.
|
||||
|
||||
**Note**: Script opens browser with article filled in. User reviews and publishes manually.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Chrome debug port not ready
|
||||
|
||||
If a script fails with `Chrome debug port not ready` or `Unable to connect`, kill existing Chrome CDP instances first, then retry:
|
||||
|
||||
```bash
|
||||
pkill -f "Chrome.*remote-debugging-port" 2>/dev/null; pkill -f "Chromium.*remote-debugging-port" 2>/dev/null; sleep 2
|
||||
```
|
||||
|
||||
**Important**: This should be done automatically — when encountering this error, kill Chrome CDP instances and retry the command without asking the user.
|
||||
|
||||
## Notes
|
||||
|
||||
- First run: manual login required (session persists)
|
||||
- Always preview before `--submit`
|
||||
- All scripts only fill content into the browser, user must review and publish manually
|
||||
- Cross-platform: macOS, Linux, Windows
|
||||
|
||||
## Extension Support
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "baoyu-post-to-x-scripts",
|
||||
"dependencies": {
|
||||
"front-matter": "^4.0.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^15.0.6",
|
||||
"remark-cjk-friendly": "^1.1.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-stringify": "^11.0.0",
|
||||
"unified": "^11.0.5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"front-matter": ["front-matter@4.0.2", "", { "dependencies": { "js-yaml": "^3.13.1" } }, "sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg=="],
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
|
||||
|
||||
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
|
||||
|
||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
|
||||
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||
|
||||
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
|
||||
|
||||
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
|
||||
|
||||
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
|
||||
|
||||
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
|
||||
|
||||
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||
|
||||
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||
|
||||
"micromark-extension-cjk-friendly": ["micromark-extension-cjk-friendly@1.2.3", "", { "dependencies": { "devlop": "^1.1.0", "micromark-extension-cjk-friendly-util": "2.1.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-gRzVLUdjXBLX6zNPSnHGDoo+ZTp5zy+MZm0g3sv+3chPXY7l9gW+DnrcHcZh/jiPR6MjPKO4AEJNp4Aw6V9z5Q=="],
|
||||
|
||||
"micromark-extension-cjk-friendly-util": ["micromark-extension-cjk-friendly-util@2.1.1", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "micromark-util-character": "^2.1.1", "micromark-util-symbol": "^2.0.1" } }, "sha512-egs6+12JU2yutskHY55FyR48ZiEcFOJFyk9rsiyIhcJ6IvWB6ABBqVrBw8IobqJTDZ/wdSr9eoXDPb5S2nW1bg=="],
|
||||
|
||||
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
|
||||
|
||||
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
|
||||
|
||||
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
|
||||
|
||||
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
|
||||
|
||||
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
|
||||
|
||||
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||
|
||||
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
|
||||
|
||||
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
|
||||
|
||||
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
|
||||
|
||||
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
|
||||
|
||||
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
|
||||
|
||||
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
||||
|
||||
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
|
||||
|
||||
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
|
||||
|
||||
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
|
||||
|
||||
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
|
||||
|
||||
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
|
||||
|
||||
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
|
||||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"remark-cjk-friendly": ["remark-cjk-friendly@1.2.3", "", { "dependencies": { "micromark-extension-cjk-friendly": "1.2.3" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-UvAgxwlNk+l9Oqgl/9MWK2eWRS7zgBW/nXX9AthV7nd/3lNejF138E7Xbmk9Zs4WjTJGs721r7fAEc7tNFoH7g=="],
|
||||
|
||||
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
|
||||
|
||||
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
|
||||
|
||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
|
||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||
|
||||
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||
|
||||
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||
|
||||
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
|
||||
|
||||
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
|
||||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { findChromeExecutable, CHROME_CANDIDATES_FULL, getDefaultProfileDir } from './x-utils.js';
|
||||
|
||||
interface CheckResult {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
const results: CheckResult[] = [];
|
||||
|
||||
function log(label: string, ok: boolean, detail: string): void {
|
||||
results.push({ name: label, ok, detail });
|
||||
const icon = ok ? '✅' : '❌';
|
||||
console.log(`${icon} ${label}: ${detail}`);
|
||||
}
|
||||
|
||||
function warn(label: string, detail: string): void {
|
||||
results.push({ name: label, ok: true, detail });
|
||||
console.log(`⚠️ ${label}: ${detail}`);
|
||||
}
|
||||
|
||||
async function checkChrome(): Promise<void> {
|
||||
const chromePath = findChromeExecutable(CHROME_CANDIDATES_FULL);
|
||||
if (chromePath) {
|
||||
log('Chrome', true, chromePath);
|
||||
} else {
|
||||
log('Chrome', false, 'Not found. Set X_BROWSER_CHROME_PATH env var or install Chrome.');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkProfileIsolation(): Promise<void> {
|
||||
const profileDir = getDefaultProfileDir();
|
||||
const userChromeDir = process.platform === 'darwin'
|
||||
? path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome')
|
||||
: process.platform === 'win32'
|
||||
? path.join(os.homedir(), 'AppData', 'Local', 'Google', 'Chrome', 'User Data')
|
||||
: path.join(os.homedir(), '.config', 'google-chrome');
|
||||
|
||||
const isIsolated = !profileDir.startsWith(userChromeDir);
|
||||
log('Profile isolation', isIsolated, `Skill profile: ${profileDir}`);
|
||||
|
||||
if (isIsolated) {
|
||||
const exists = fs.existsSync(profileDir);
|
||||
if (exists) {
|
||||
log('Profile dir', true, 'Exists and accessible');
|
||||
} else {
|
||||
try {
|
||||
fs.mkdirSync(profileDir, { recursive: true });
|
||||
log('Profile dir', true, 'Created successfully');
|
||||
} catch (e) {
|
||||
log('Profile dir', false, `Cannot create: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAccessibility(): Promise<void> {
|
||||
if (process.platform !== 'darwin') {
|
||||
log('Accessibility', true, `Skipped (not macOS, platform: ${process.platform})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = spawnSync('osascript', ['-e', `
|
||||
tell application "System Events"
|
||||
set frontApp to name of first application process whose frontmost is true
|
||||
return frontApp
|
||||
end tell
|
||||
`], { stdio: 'pipe', timeout: 10_000 });
|
||||
|
||||
if (result.status === 0) {
|
||||
const app = result.stdout?.toString().trim();
|
||||
log('Accessibility (System Events)', true, `Frontmost app: ${app}`);
|
||||
} else {
|
||||
const stderr = result.stderr?.toString().trim() || '';
|
||||
if (stderr.includes('not allowed assistive access') || stderr.includes('1002')) {
|
||||
log('Accessibility (System Events)', false,
|
||||
'Denied. Grant access: System Settings → Privacy & Security → Accessibility → enable your terminal app');
|
||||
} else {
|
||||
log('Accessibility (System Events)', false, `Failed: ${stderr}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkClipboardCopy(): Promise<void> {
|
||||
if (process.platform !== 'darwin') {
|
||||
log('Clipboard copy (image)', true, `Skipped (not macOS)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'x-check-'));
|
||||
try {
|
||||
const testPng = path.join(tmpDir, 'test.png');
|
||||
const swiftSrc = `import AppKit
|
||||
import Foundation
|
||||
let size = NSSize(width: 2, height: 2)
|
||||
let image = NSImage(size: size)
|
||||
image.lockFocus()
|
||||
NSColor.red.set()
|
||||
NSBezierPath.fill(NSRect(origin: .zero, size: size))
|
||||
image.unlockFocus()
|
||||
guard let tiff = image.tiffRepresentation,
|
||||
let rep = NSBitmapImageRep(data: tiff),
|
||||
let png = rep.representation(using: .png, properties: [:]) else {
|
||||
FileHandle.standardError.write("Failed to create test PNG\\n".data(using: .utf8)!)
|
||||
exit(1)
|
||||
}
|
||||
try png.write(to: URL(fileURLWithPath: CommandLine.arguments[1]))
|
||||
`;
|
||||
const genScript = path.join(tmpDir, 'gen.swift');
|
||||
await writeFile(genScript, swiftSrc, 'utf8');
|
||||
const genResult = spawnSync('swift', [genScript, testPng], { stdio: 'pipe', timeout: 30_000 });
|
||||
if (genResult.status !== 0) {
|
||||
log('Clipboard copy (image)', false, `Cannot create test image: ${genResult.stderr?.toString().trim()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const clipSrc = `import AppKit
|
||||
import Foundation
|
||||
guard let image = NSImage(contentsOfFile: CommandLine.arguments[1]) else {
|
||||
FileHandle.standardError.write("Failed to load image\\n".data(using: .utf8)!)
|
||||
exit(1)
|
||||
}
|
||||
let pb = NSPasteboard.general
|
||||
pb.clearContents()
|
||||
if !pb.writeObjects([image]) {
|
||||
FileHandle.standardError.write("Failed to write to clipboard\\n".data(using: .utf8)!)
|
||||
exit(1)
|
||||
}
|
||||
`;
|
||||
const clipScript = path.join(tmpDir, 'clip.swift');
|
||||
await writeFile(clipScript, clipSrc, 'utf8');
|
||||
const clipResult = spawnSync('swift', [clipScript, testPng], { stdio: 'pipe', timeout: 30_000 });
|
||||
if (clipResult.status === 0) {
|
||||
log('Clipboard copy (image)', true, 'Can copy image to clipboard via Swift/AppKit');
|
||||
} else {
|
||||
log('Clipboard copy (image)', false, `Failed: ${clipResult.stderr?.toString().trim()}`);
|
||||
}
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function checkPasteKeystroke(): Promise<void> {
|
||||
if (process.platform === 'darwin') {
|
||||
const result = spawnSync('osascript', ['-e', `
|
||||
tell application "System Events"
|
||||
-- Dry run: just check we CAN query key sending capability
|
||||
set canSend to true
|
||||
return canSend
|
||||
end tell
|
||||
`], { stdio: 'pipe', timeout: 10_000 });
|
||||
|
||||
if (result.status === 0) {
|
||||
log('Paste keystroke (osascript)', true, 'System Events can send keystrokes');
|
||||
} else {
|
||||
const stderr = result.stderr?.toString().trim() || '';
|
||||
log('Paste keystroke (osascript)', false, `Cannot send keystrokes: ${stderr}`);
|
||||
}
|
||||
} else if (process.platform === 'linux') {
|
||||
const xdotool = spawnSync('which', ['xdotool'], { stdio: 'pipe' });
|
||||
const ydotool = spawnSync('which', ['ydotool'], { stdio: 'pipe' });
|
||||
if (xdotool.status === 0) {
|
||||
log('Paste keystroke', true, 'xdotool available (X11)');
|
||||
} else if (ydotool.status === 0) {
|
||||
log('Paste keystroke', true, 'ydotool available (Wayland)');
|
||||
} else {
|
||||
log('Paste keystroke', false, 'No tool found. Install xdotool (X11) or ydotool (Wayland).');
|
||||
}
|
||||
} else if (process.platform === 'win32') {
|
||||
log('Paste keystroke', true, 'Windows uses PowerShell SendKeys (built-in)');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkBun(): Promise<void> {
|
||||
const result = spawnSync('npx', ['-y', 'bun', '--version'], { stdio: 'pipe', timeout: 30_000 });
|
||||
if (result.status === 0) {
|
||||
log('Bun runtime', true, `v${result.stdout?.toString().trim()}`);
|
||||
} else {
|
||||
log('Bun runtime', false, 'Cannot run bun. Install: curl -fsSL https://bun.sh/install | bash');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkRunningChromeConflict(): Promise<void> {
|
||||
if (process.platform !== 'darwin') return;
|
||||
|
||||
const result = spawnSync('pgrep', ['-f', 'Google Chrome'], { stdio: 'pipe' });
|
||||
const pids = result.stdout?.toString().trim().split('\n').filter(Boolean) || [];
|
||||
|
||||
if (pids.length > 0) {
|
||||
warn('Running Chrome instances', `${pids.length} Chrome process(es) detected. The skill uses --user-data-dir for isolation, so this is safe. Paste keystroke targets Chrome by app name (minor risk if multiple Chrome windows visible).`);
|
||||
} else {
|
||||
log('Running Chrome instances', true, 'No existing Chrome processes');
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log('=== baoyu-post-to-x: Permission & Environment Check ===\n');
|
||||
|
||||
await checkChrome();
|
||||
await checkProfileIsolation();
|
||||
await checkBun();
|
||||
await checkAccessibility();
|
||||
await checkClipboardCopy();
|
||||
await checkPasteKeystroke();
|
||||
await checkRunningChromeConflict();
|
||||
|
||||
console.log('\n--- Summary ---');
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
if (failed.length === 0) {
|
||||
console.log('All checks passed. Ready to post to X.');
|
||||
} else {
|
||||
console.log(`${failed.length} issue(s) found:`);
|
||||
for (const f of failed) {
|
||||
console.log(` ❌ ${f.name}: ${f.detail}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
await main().catch((err) => {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -228,25 +228,25 @@ async function copyHtmlLinux(htmlFilePath: string): Promise<void> {
|
||||
}
|
||||
|
||||
async function copyImageWindows(imagePath: string): Promise<void> {
|
||||
const escaped = imagePath.replace(/'/g, "''");
|
||||
const ps = [
|
||||
'param([string]$Path)',
|
||||
'Add-Type -AssemblyName System.Windows.Forms',
|
||||
'Add-Type -AssemblyName System.Drawing',
|
||||
'$img = [System.Drawing.Image]::FromFile($Path)',
|
||||
`$img = [System.Drawing.Image]::FromFile('${escaped}')`,
|
||||
'[System.Windows.Forms.Clipboard]::SetImage($img)',
|
||||
'$img.Dispose()',
|
||||
].join('; ');
|
||||
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps, '-Path', imagePath]);
|
||||
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps]);
|
||||
}
|
||||
|
||||
async function copyHtmlWindows(htmlFilePath: string): Promise<void> {
|
||||
const escaped = htmlFilePath.replace(/'/g, "''");
|
||||
const ps = [
|
||||
'param([string]$Path)',
|
||||
'Add-Type -AssemblyName System.Windows.Forms',
|
||||
'$html = Get-Content -Raw -LiteralPath $Path',
|
||||
`$html = Get-Content -Raw -LiteralPath '${escaped}'`,
|
||||
'[System.Windows.Forms.Clipboard]::SetText($html, [System.Windows.Forms.TextDataFormat]::Html)',
|
||||
].join('; ');
|
||||
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps, '-Path', htmlFilePath]);
|
||||
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps]);
|
||||
}
|
||||
|
||||
async function copyImageToClipboard(imagePathInput: string): Promise<void> {
|
||||
|
||||
@@ -7,6 +7,14 @@ import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import frontMatter from 'front-matter';
|
||||
import hljs from 'highlight.js/lib/common';
|
||||
import { Lexer, Marked, type RendererObject, type Tokens } from 'marked';
|
||||
import { unified } from 'unified';
|
||||
import remarkCjkFriendly from 'remark-cjk-friendly';
|
||||
import remarkParse from 'remark-parse';
|
||||
import remarkStringify from 'remark-stringify';
|
||||
|
||||
interface ImageInfo {
|
||||
placeholder: string;
|
||||
localPath: string;
|
||||
@@ -22,25 +30,80 @@ interface ParsedMarkdown {
|
||||
totalBlocks: number;
|
||||
}
|
||||
|
||||
function parseFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } {
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
||||
if (!match) return { frontmatter: {}, body: content };
|
||||
type FrontmatterFields = Record<string, unknown>;
|
||||
|
||||
const frontmatter: Record<string, string> = {};
|
||||
const lines = match[1]!.split('\n');
|
||||
for (const line of lines) {
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
const key = line.slice(0, colonIdx).trim();
|
||||
let value = line.slice(colonIdx + 1).trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
function parseFrontmatter(content: string): { frontmatter: FrontmatterFields; body: string } {
|
||||
try {
|
||||
const parsed = frontMatter<FrontmatterFields>(content);
|
||||
return {
|
||||
frontmatter: parsed.attributes ?? {},
|
||||
body: parsed.body,
|
||||
};
|
||||
} catch {
|
||||
return { frontmatter: {}, body: content };
|
||||
}
|
||||
}
|
||||
|
||||
function stripWrappingQuotes(value: string): string {
|
||||
if (!value) return value;
|
||||
const doubleQuoted = value.startsWith('"') && value.endsWith('"');
|
||||
const singleQuoted = value.startsWith("'") && value.endsWith("'");
|
||||
const cjkDoubleQuoted = value.startsWith('\u201c') && value.endsWith('\u201d');
|
||||
const cjkSingleQuoted = value.startsWith('\u2018') && value.endsWith('\u2019');
|
||||
if (doubleQuoted || singleQuoted || cjkDoubleQuoted || cjkSingleQuoted) {
|
||||
return value.slice(1, -1).trim();
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function toFrontmatterString(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') {
|
||||
return stripWrappingQuotes(value);
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pickFirstString(frontmatter: FrontmatterFields, keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = toFrontmatterString(frontmatter[key]);
|
||||
if (value) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findCoverImageNearMarkdown(baseDir: string): string | null {
|
||||
const candidateDirs = [baseDir, path.join(baseDir, 'imgs')];
|
||||
const coverPattern = /^cover\.(png|jpe?g|webp)$/i;
|
||||
|
||||
for (const dir of candidateDirs) {
|
||||
try {
|
||||
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
frontmatter[key] = value;
|
||||
|
||||
const match = fs.readdirSync(dir).find((entry) => coverPattern.test(entry));
|
||||
if (match) {
|
||||
return path.join(dir, match);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return { frontmatter, body: match[2]! };
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractTitleFromMarkdown(markdown: string): string {
|
||||
const tokens = Lexer.lex(markdown, { gfm: true, breaks: true });
|
||||
for (const token of tokens) {
|
||||
if (token.type === 'heading' && token.depth === 1) {
|
||||
return stripWrappingQuotes(token.text);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function downloadFile(url: string, destPath: string): Promise<void> {
|
||||
@@ -116,141 +179,99 @@ function escapeHtml(text: string): string {
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function highlightCode(code: string, lang: string): string {
|
||||
try {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
|
||||
}
|
||||
return hljs.highlightAuto(code).value;
|
||||
} catch {
|
||||
return escapeHtml(code);
|
||||
}
|
||||
}
|
||||
|
||||
function preprocessCjkMarkdown(markdown: string): string {
|
||||
try {
|
||||
const processor = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkCjkFriendly)
|
||||
.use(remarkStringify);
|
||||
|
||||
const result = String(processor.processSync(markdown));
|
||||
return result.replace(/&#x([0-9A-Fa-f]+);/g, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)));
|
||||
} catch {
|
||||
return markdown;
|
||||
}
|
||||
}
|
||||
|
||||
function convertMarkdownToHtml(markdown: string, imageCallback: (src: string, alt: string) => string): { html: string; totalBlocks: number } {
|
||||
const lines = markdown.split('\n');
|
||||
const blocks: string[] = [];
|
||||
let inCodeBlock = false;
|
||||
let codeBlockContent: string[] = [];
|
||||
let inList = false;
|
||||
let listItems: string[] = [];
|
||||
let listType: 'ul' | 'ol' = 'ul';
|
||||
const preprocessedMarkdown = preprocessCjkMarkdown(markdown);
|
||||
const blockTokens = Lexer.lex(preprocessedMarkdown, { gfm: true, breaks: true });
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length > 0) {
|
||||
const tag = listType === 'ol' ? 'ol' : 'ul';
|
||||
blocks.push(`<${tag}>${listItems.map((item) => `<li>${item}</li>`).join('')}</${tag}>`);
|
||||
listItems = [];
|
||||
inList = false;
|
||||
}
|
||||
const renderer: RendererObject = {
|
||||
heading({ depth, tokens }: Tokens.Heading): string {
|
||||
if (depth === 1) {
|
||||
return '';
|
||||
}
|
||||
return `<h2>${this.parser.parseInline(tokens)}</h2>`;
|
||||
},
|
||||
|
||||
paragraph({ tokens }: Tokens.Paragraph): string {
|
||||
const text = this.parser.parseInline(tokens).trim();
|
||||
if (!text) return '';
|
||||
return `<p>${text}</p>`;
|
||||
},
|
||||
|
||||
blockquote({ tokens }: Tokens.Blockquote): string {
|
||||
return `<blockquote>${this.parser.parse(tokens)}</blockquote>`;
|
||||
},
|
||||
|
||||
code({ text, lang = '' }: Tokens.Code): string {
|
||||
const language = lang.split(/\s+/)[0]!.toLowerCase();
|
||||
const source = text.replace(/\n$/, '');
|
||||
const highlighted = highlightCode(source, language).replace(/\n/g, '<br>');
|
||||
const label = language ? `<strong>[${escapeHtml(language)}]</strong><br>` : '';
|
||||
return `<blockquote>${label}${highlighted}</blockquote>`;
|
||||
},
|
||||
|
||||
image({ href, text }: Tokens.Image): string {
|
||||
if (!href) return '';
|
||||
return imageCallback(href, text ?? '');
|
||||
},
|
||||
|
||||
link({ href, title, tokens, text }: Tokens.Link): string {
|
||||
const label = tokens?.length ? this.parser.parseInline(tokens) : escapeHtml(text || href || '');
|
||||
if (!href) return label;
|
||||
|
||||
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
|
||||
return `<a href="${escapeHtml(href)}"${titleAttr} rel="noopener noreferrer nofollow">${label}</a>`;
|
||||
},
|
||||
};
|
||||
|
||||
const processInline = (text: string): string => {
|
||||
// Bold
|
||||
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
text = text.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
||||
const parser = new Marked({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
});
|
||||
parser.use({ renderer });
|
||||
|
||||
// Italic
|
||||
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
text = text.replace(/_(.+?)_/g, '<em>$1</em>');
|
||||
|
||||
// Links
|
||||
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
||||
|
||||
// Inline code
|
||||
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
|
||||
// Code block
|
||||
if (line.startsWith('```')) {
|
||||
if (inCodeBlock) {
|
||||
// X doesn't support <pre><code>, convert to blockquote
|
||||
const codeContent = codeBlockContent.map((l) => escapeHtml(l)).join('<br>');
|
||||
blocks.push(`<blockquote>${codeContent}</blockquote>`);
|
||||
codeBlockContent = [];
|
||||
inCodeBlock = false;
|
||||
} else {
|
||||
flushList();
|
||||
inCodeBlock = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inCodeBlock) {
|
||||
codeBlockContent.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Empty line
|
||||
if (line.trim() === '') {
|
||||
flushList();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Image
|
||||
const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/);
|
||||
if (imgMatch) {
|
||||
flushList();
|
||||
const placeholder = imageCallback(imgMatch[2]!, imgMatch[1]!);
|
||||
blocks.push(`<p>${placeholder}</p>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Heading (H1 is title, skip it; H2-H6 become H2)
|
||||
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
flushList();
|
||||
const level = headingMatch[1]!.length;
|
||||
if (level === 1) continue; // Skip H1, it's the title
|
||||
blocks.push(`<h2>${processInline(headingMatch[2]!)}</h2>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
if (line.startsWith('> ')) {
|
||||
flushList();
|
||||
blocks.push(`<blockquote>${processInline(line.slice(2))}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
const ulMatch = line.match(/^[-*]\s+(.+)$/);
|
||||
if (ulMatch) {
|
||||
if (!inList || listType !== 'ul') {
|
||||
flushList();
|
||||
inList = true;
|
||||
listType = 'ul';
|
||||
}
|
||||
listItems.push(processInline(ulMatch[1]!));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
const olMatch = line.match(/^\d+\.\s+(.+)$/);
|
||||
if (olMatch) {
|
||||
if (!inList || listType !== 'ol') {
|
||||
flushList();
|
||||
inList = true;
|
||||
listType = 'ol';
|
||||
}
|
||||
listItems.push(processInline(olMatch[1]!));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (/^[-*_]{3,}\s*$/.test(line)) {
|
||||
flushList();
|
||||
blocks.push('<hr>');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular paragraph
|
||||
flushList();
|
||||
blocks.push(`<p>${processInline(line)}</p>`);
|
||||
const rendered = parser.parse(preprocessedMarkdown);
|
||||
if (typeof rendered !== 'string') {
|
||||
throw new Error('Unexpected async markdown parse result');
|
||||
}
|
||||
|
||||
flushList();
|
||||
const totalBlocks = blockTokens.filter((token) => {
|
||||
if (token.type === 'space') return false;
|
||||
if (token.type === 'heading' && token.depth === 1) return false;
|
||||
return true;
|
||||
}).length;
|
||||
|
||||
return {
|
||||
html: blocks.join('\n'),
|
||||
totalBlocks: blocks.length,
|
||||
html: rendered,
|
||||
totalBlocks,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -266,59 +287,58 @@ export async function parseMarkdown(
|
||||
|
||||
const { frontmatter, body } = parseFrontmatter(content);
|
||||
|
||||
// Extract title from frontmatter, option, or first H1
|
||||
let title = options?.title ?? frontmatter.title ?? '';
|
||||
let title = stripWrappingQuotes(options?.title ?? '') || pickFirstString(frontmatter, ['title']) || '';
|
||||
if (!title) {
|
||||
const h1Match = body.match(/^#\s+(.+)$/m);
|
||||
if (h1Match) title = h1Match[1]!;
|
||||
title = extractTitleFromMarkdown(body);
|
||||
}
|
||||
if (!title) {
|
||||
title = path.basename(markdownPath, path.extname(markdownPath));
|
||||
}
|
||||
|
||||
// Extract cover image from frontmatter or option
|
||||
let coverImagePath = options?.coverImage ?? frontmatter.cover_image ?? frontmatter.coverImage ?? frontmatter.cover ?? frontmatter.image ?? frontmatter.featureImage ?? frontmatter.feature_image ?? null;
|
||||
let coverImagePath = stripWrappingQuotes(options?.coverImage ?? '') || pickFirstString(frontmatter, [
|
||||
'cover_image',
|
||||
'coverImage',
|
||||
'cover',
|
||||
'image',
|
||||
'featureImage',
|
||||
'feature_image',
|
||||
]) || null;
|
||||
if (!coverImagePath) {
|
||||
coverImagePath = findCoverImageNearMarkdown(baseDir);
|
||||
}
|
||||
|
||||
const images: Array<{ src: string; alt: string; blockIndex: number }> = [];
|
||||
let imageCounter = 0;
|
||||
|
||||
const { html, totalBlocks } = convertMarkdownToHtml(body, (src, alt) => {
|
||||
const placeholder = `XIMGPH_${++imageCounter}`;
|
||||
const currentBlockIndex = images.length; // Will be set properly after HTML generation
|
||||
|
||||
images.push({ src, alt, blockIndex: -1 }); // blockIndex set later
|
||||
images.push({ src, alt, blockIndex: -1 });
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// Update block indices by finding placeholders in HTML
|
||||
const htmlLines = html.split('\n');
|
||||
let blockIdx = 0;
|
||||
for (const line of htmlLines) {
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const placeholder = `XIMGPH_${i + 1}`;
|
||||
if (line.includes(placeholder)) {
|
||||
images[i]!.blockIndex = blockIdx;
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const placeholder = `XIMGPH_${i + 1}`;
|
||||
for (let lineIndex = 0; lineIndex < htmlLines.length; lineIndex++) {
|
||||
const regex = new RegExp(`\\b${placeholder}\\b`);
|
||||
if (regex.test(htmlLines[lineIndex]!)) {
|
||||
images[i]!.blockIndex = lineIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
blockIdx++;
|
||||
}
|
||||
|
||||
// Resolve image paths (download remote, resolve relative)
|
||||
const contentImages: ImageInfo[] = [];
|
||||
let isFirstImage = true;
|
||||
let coverPlaceholder: string | null = null;
|
||||
let firstImageAsCover: string | null = null;
|
||||
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const img = images[i]!;
|
||||
const localPath = await resolveImagePath(img.src, baseDir, tempDir);
|
||||
|
||||
// First image becomes cover if no cover specified
|
||||
if (isFirstImage && !coverImagePath) {
|
||||
coverImagePath = localPath;
|
||||
coverPlaceholder = `XIMGPH_${i + 1}`;
|
||||
isFirstImage = false;
|
||||
// Don't add to contentImages, it's the cover
|
||||
continue;
|
||||
if (i === 0 && !coverImagePath) {
|
||||
firstImageAsCover = localPath;
|
||||
}
|
||||
|
||||
isFirstImage = false;
|
||||
contentImages.push({
|
||||
placeholder: `XIMGPH_${i + 1}`,
|
||||
localPath,
|
||||
@@ -327,17 +347,13 @@ export async function parseMarkdown(
|
||||
});
|
||||
}
|
||||
|
||||
// Remove cover placeholder from HTML if first image was used as cover
|
||||
let finalHtml = html;
|
||||
if (coverPlaceholder) {
|
||||
// Remove the placeholder and its containing <p> tag
|
||||
finalHtml = finalHtml.replace(new RegExp(`<p>${coverPlaceholder}</p>\\n?`, 'g'), '');
|
||||
}
|
||||
const finalHtml = html.replace(/\n{3,}/g, '\n\n').trim();
|
||||
|
||||
// Resolve cover image path
|
||||
let resolvedCoverImage: string | null = null;
|
||||
if (coverImagePath) {
|
||||
resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir);
|
||||
} else if (firstImageAsCover) {
|
||||
resolvedCoverImage = firstImageAsCover;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "baoyu-post-to-x-scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"front-matter": "^4.0.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^15.0.6",
|
||||
"remark-cjk-friendly": "^1.1.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-stringify": "^11.0.0",
|
||||
"unified": "^11.0.5"
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,7 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||
console.log(`[x-article] Reusing existing Chrome instance on port ${port}`);
|
||||
} else {
|
||||
console.log(`[x-article] Launching Chrome...`);
|
||||
spawn(chromePath, [
|
||||
const chromeArgs = [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profileDir}`,
|
||||
'--no-first-run',
|
||||
@@ -117,7 +117,13 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--start-maximized',
|
||||
X_ARTICLES_URL,
|
||||
], { stdio: 'ignore' });
|
||||
];
|
||||
if (process.platform === 'darwin') {
|
||||
const appPath = chromePath.replace(/\/Contents\/MacOS\/Google Chrome$/, '');
|
||||
spawn('open', ['-na', appPath, '--args', ...chromeArgs], { stdio: 'ignore' });
|
||||
} else {
|
||||
spawn(chromePath, chromeArgs, { stdio: 'ignore' });
|
||||
}
|
||||
}
|
||||
|
||||
let cdp: CdpConnection | null = null;
|
||||
@@ -596,6 +602,12 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
// Count existing image blocks before paste
|
||||
const imgCountBefore = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {
|
||||
expression: `document.querySelectorAll('section[data-block="true"][contenteditable="false"] img[src^="blob:"]').length`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
|
||||
// Focus editor to ensure cursor is in position
|
||||
await cdp.send('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
@@ -613,9 +625,31 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||
console.warn(`[x-article] Failed to paste image after retries`);
|
||||
}
|
||||
|
||||
// Wait for image to upload
|
||||
console.log(`[x-article] Waiting for upload...`);
|
||||
await sleep(5000);
|
||||
// Verify image appeared in editor
|
||||
console.log(`[x-article] Verifying image upload...`);
|
||||
const expectedImgCount = imgCountBefore.result.value + 1;
|
||||
let imgUploadOk = false;
|
||||
const imgWaitStart = Date.now();
|
||||
while (Date.now() - imgWaitStart < 15_000) {
|
||||
const r = await cdp!.send<{ result: { value: number } }>('Runtime.evaluate', {
|
||||
expression: `document.querySelectorAll('section[data-block="true"][contenteditable="false"] img[src^="blob:"]').length`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
if (r.result.value >= expectedImgCount) {
|
||||
imgUploadOk = true;
|
||||
break;
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
if (imgUploadOk) {
|
||||
console.log(`[x-article] Image upload verified (${expectedImgCount} image block(s))`);
|
||||
} else {
|
||||
console.warn(`[x-article] Image upload not detected after 15s`);
|
||||
if (i === 0) {
|
||||
console.error('[x-article] First image paste failed. Run check-paste-permissions.ts to diagnose.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[x-article] All images processed.');
|
||||
@@ -732,7 +766,8 @@ async function main(): Promise<void> {
|
||||
if (arg === '--title' && args[i + 1]) {
|
||||
title = args[++i];
|
||||
} else if (arg === '--cover' && args[i + 1]) {
|
||||
coverImage = args[++i];
|
||||
const raw = args[++i]!;
|
||||
coverImage = path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);
|
||||
} else if (arg === '--submit') {
|
||||
submit = true;
|
||||
} else if (arg === '--profile' && args[i + 1]) {
|
||||
|
||||
@@ -117,6 +117,12 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Count uploaded images before paste
|
||||
const imgCountBefore = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {
|
||||
expression: `document.querySelectorAll('img[src^="blob:"]').length`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
|
||||
// Wait for clipboard to be ready
|
||||
await sleep(500);
|
||||
|
||||
@@ -150,8 +156,27 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
|
||||
}, { sessionId });
|
||||
}
|
||||
|
||||
console.log('[x-browser] Waiting for image upload...');
|
||||
await sleep(4000);
|
||||
console.log('[x-browser] Verifying image upload...');
|
||||
const expectedImgCount = imgCountBefore.result.value + 1;
|
||||
let imgUploadOk = false;
|
||||
const imgWaitStart = Date.now();
|
||||
while (Date.now() - imgWaitStart < 15_000) {
|
||||
const r = await cdp!.send<{ result: { value: number } }>('Runtime.evaluate', {
|
||||
expression: `document.querySelectorAll('img[src^="blob:"]').length`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
if (r.result.value >= expectedImgCount) {
|
||||
imgUploadOk = true;
|
||||
break;
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
if (imgUploadOk) {
|
||||
console.log('[x-browser] Image upload verified');
|
||||
} else {
|
||||
console.warn('[x-browser] Image upload not detected after 15s. Run check-paste-permissions.ts to diagnose.');
|
||||
}
|
||||
}
|
||||
|
||||
if (submit) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export type PlatformCandidates = {
|
||||
darwin?: string[];
|
||||
@@ -216,7 +217,7 @@ export class CdpConnection {
|
||||
}
|
||||
|
||||
export function getScriptDir(): string {
|
||||
return path.dirname(new URL(import.meta.url).pathname);
|
||||
return path.dirname(fileURLToPath(import.meta.url));
|
||||
}
|
||||
|
||||
function runBunScript(scriptPath: string, args: string[]): boolean {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -368,10 +368,21 @@ Display the selected style's default elements from preset, then ask:
|
||||
|
||||
With confirmed outline + style + layout:
|
||||
|
||||
**Visual Consistency — Reference Image Chain**:
|
||||
To ensure character/style consistency across all images in a series:
|
||||
1. **Generate image 1 (cover) FIRST** — without `--ref`
|
||||
2. **Use image 1 as `--ref` for ALL remaining images** (2, 3, ..., N)
|
||||
- This anchors the character design, color rendering, and illustration style
|
||||
- Command pattern: `--ref <path-to-image-01.png>` added to every subsequent generation
|
||||
|
||||
This is critical for styles that use recurring characters, mascots, or illustration elements. Image 1 becomes the visual anchor for the entire series.
|
||||
|
||||
**For each image (cover + content + ending)**:
|
||||
1. Save prompt to `prompts/NN-{type}-[slug].md` (in user's preferred language)
|
||||
- **Backup rule**: If prompt file exists, rename to `prompts/NN-{type}-[slug]-backup-YYYYMMDD-HHMMSS.md`
|
||||
2. Generate image using confirmed style and layout
|
||||
2. Generate image:
|
||||
- **Image 1**: Generate without `--ref` (this establishes the visual anchor)
|
||||
- **Images 2+**: Generate with `--ref <image-01-path>` for consistency
|
||||
- **Backup rule**: If image file exists, rename to `NN-{type}-[slug]-backup-YYYYMMDD-HHMMSS.png`
|
||||
3. Report progress after each generation
|
||||
|
||||
@@ -391,7 +402,7 @@ Reference: `references/config/watermark-guide.md`
|
||||
If image generation skill supports `--sessionId`:
|
||||
1. Generate unique session ID: `xhs-{topic-slug}-{timestamp}`
|
||||
2. Use same session ID for all images
|
||||
3. Ensures visual consistency across generated images
|
||||
3. Combined with reference image chain, ensures maximum visual consistency
|
||||
|
||||
### Step 6: Completion Report
|
||||
|
||||
|
||||
@@ -160,7 +160,21 @@ From outline entry, format:
|
||||
If preferences include watermark:
|
||||
- Add watermark section with content, position, opacity
|
||||
|
||||
### Step 5: Combine
|
||||
### Step 5: Visual Consistency — Reference Image Chain
|
||||
|
||||
When generating multiple images in a series:
|
||||
|
||||
1. **Image 1 (cover)**: Generate without `--ref` — this establishes the visual anchor
|
||||
2. **Images 2+**: Always pass image 1 as `--ref` to the image generation skill:
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts \
|
||||
--promptfiles prompts/02-content-xxx.md \
|
||||
--ref path/to/01-cover-xxx.png \
|
||||
--image 02-content-xxx.png --ar 3:4 --quality 2k
|
||||
```
|
||||
This ensures the AI maintains the same character design, illustration style, and color rendering across the series.
|
||||
|
||||
### Step 6: Combine
|
||||
|
||||
Assemble all sections into final prompt following base structure.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user