mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 05:51:44 +08:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||||
"version": "1.28.4"
|
"version": "1.34.1"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,96 @@
|
|||||||
|
|
||||||
English | [中文](./CHANGELOG.zh.md)
|
English | [中文](./CHANGELOG.zh.md)
|
||||||
|
|
||||||
|
## 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
|
## 1.28.4 - 2026-02-03
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -2,6 +2,96 @@
|
|||||||
|
|
||||||
[English](./CHANGELOG.md) | 中文
|
[English](./CHANGELOG.md) | 中文
|
||||||
|
|
||||||
|
## 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
|
## 1.28.4 - 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:
|
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
|
```bash
|
||||||
/baoyu-post-to-wechat 图文 --markdown article.md --images ./photos/
|
/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 贴图 --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 贴图 --title "标题" --content "内容" --image img1.png --submit
|
||||||
```
|
```
|
||||||
|
|
||||||
**Article (文章)** - Full markdown/HTML with rich formatting:
|
**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
|
# JSON output
|
||||||
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --json
|
/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:**
|
**Supported URLs:**
|
||||||
@@ -713,7 +716,7 @@ Format plain text or markdown files with proper frontmatter, titles, summaries,
|
|||||||
|
|
||||||
**Workflow**:
|
**Workflow**:
|
||||||
1. Read source file and analyze content structure
|
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
|
3. Handle title: use existing, extract from H1, or generate candidates
|
||||||
4. Apply formatting: headings, bold, lists, code blocks, quotes
|
4. Apply formatting: headings, bold, lists, code blocks, quotes
|
||||||
5. Save to `{filename}-formatted.md`
|
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 |
|
| `title` | Use existing, extract H1, or generate candidates |
|
||||||
| `slug` | Infer from file path or generate from title |
|
| `slug` | Infer from file path or generate from title |
|
||||||
| `summary` | Generate engaging summary (100-150 chars) |
|
| `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**:
|
**Formatting Rules**:
|
||||||
| Element | Format |
|
| Element | Format |
|
||||||
|
|||||||
+9
-6
@@ -519,12 +519,12 @@ npx skills add jimliu/baoyu-skills
|
|||||||
|
|
||||||
发布内容到微信公众号,支持两种模式:
|
发布内容到微信公众号,支持两种模式:
|
||||||
|
|
||||||
**图文模式** - 多图配短标题和正文:
|
**贴图模式** - 多图配短标题和正文:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/baoyu-post-to-wechat 图文 --markdown article.md --images ./photos/
|
/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 贴图 --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 贴图 --title "标题" --content "内容" --image img1.png --submit
|
||||||
```
|
```
|
||||||
|
|
||||||
**文章模式** - 完整 markdown/HTML 富文本格式:
|
**文章模式** - 完整 markdown/HTML 富文本格式:
|
||||||
@@ -681,6 +681,9 @@ AI 驱动的生成后端。
|
|||||||
|
|
||||||
# JSON 输出
|
# JSON 输出
|
||||||
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --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:**
|
**支持的 URL:**
|
||||||
@@ -713,7 +716,7 @@ AI 驱动的生成后端。
|
|||||||
|
|
||||||
**工作流程**:
|
**工作流程**:
|
||||||
1. 读取源文件并分析内容结构
|
1. 读取源文件并分析内容结构
|
||||||
2. 检查/创建 YAML frontmatter(title、slug、summary、featureImage)
|
2. 检查/创建 YAML frontmatter(title、slug、summary、coverImage)
|
||||||
3. 处理标题:使用现有标题、提取 H1 或生成候选标题
|
3. 处理标题:使用现有标题、提取 H1 或生成候选标题
|
||||||
4. 应用格式:层级标题、加粗、列表、代码块、引用
|
4. 应用格式:层级标题、加粗、列表、代码块、引用
|
||||||
5. 保存为 `{文件名}-formatted.md`
|
5. 保存为 `{文件名}-formatted.md`
|
||||||
@@ -725,7 +728,7 @@ AI 驱动的生成后端。
|
|||||||
| `title` | 使用现有、提取 H1 或生成候选 |
|
| `title` | 使用现有、提取 H1 或生成候选 |
|
||||||
| `slug` | 从文件路径推断或根据标题生成 |
|
| `slug` | 从文件路径推断或根据标题生成 |
|
||||||
| `summary` | 生成吸引人的摘要(100-150 字) |
|
| `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 |
|
| Dimension | Controls | Examples |
|
||||||
|-----------|----------|----------|
|
|-----------|----------|----------|
|
||||||
| **Type** | Information structure, layout | infographic, scene, flowchart, comparison, framework, timeline |
|
| **Type** | Information structure | infographic, scene, flowchart, comparison, framework, timeline |
|
||||||
| **Style** | Visual aesthetics, mood | notion, warm, minimal, blueprint, watercolor, elegant |
|
| **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
|
## Types
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
| Type | Best For |
|
| Type | Best For |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `infographic` | Data, metrics, technical articles |
|
| `infographic` | Data, metrics, technical |
|
||||||
| `scene` | Narratives, personal stories, emotional content |
|
| `scene` | Narratives, emotional |
|
||||||
| `flowchart` | Tutorials, workflows, processes |
|
| `flowchart` | Processes, workflows |
|
||||||
| `comparison` | Side-by-side, before/after, options |
|
| `comparison` | Side-by-side, options |
|
||||||
| `framework` | Methodologies, models, architecture |
|
| `framework` | Models, architecture |
|
||||||
| `timeline` | History, progress, evolution |
|
| `timeline` | History, evolution |
|
||||||
|
|
||||||
## Styles
|
## Styles
|
||||||
|
|
||||||
See [references/styles.md](references/styles.md) for:
|
See [references/styles.md](references/styles.md) for Core Styles, full gallery, and Type × Style compatibility.
|
||||||
- **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
|
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
```
|
```
|
||||||
Progress:
|
- [ ] Step 1: Pre-check (EXTEND.md, references, config)
|
||||||
- [ ] Step 1: Pre-check
|
- [ ] Step 2: Analyze content
|
||||||
- [ ] 1.5 Load preferences (EXTEND.md) ⛔ BLOCKING
|
- [ ] Step 3: Confirm settings (AskUserQuestion)
|
||||||
- [ ] 1.0 Reference images ⚠️ (if provided)
|
- [ ] Step 4: Generate outline
|
||||||
- [ ] 1.2-1.4 Config questions (1 AskUserQuestion, max 4 Qs)
|
- [ ] Step 5: Generate images
|
||||||
- [ ] 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 6: Finalize
|
- [ ] Step 6: Finalize
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -67,8 +46,6 @@ Progress:
|
|||||||
|
|
||||||
**1.5 Load Preferences (EXTEND.md) ⛔ BLOCKING**
|
**1.5 Load Preferences (EXTEND.md) ⛔ BLOCKING**
|
||||||
|
|
||||||
**CRITICAL**: If EXTEND.md not found, MUST complete first-time setup before ANY other steps.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
test -f .baoyu-skills/baoyu-article-illustrator/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-article-illustrator/EXTEND.md && echo "project"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-article-illustrator/EXTEND.md" && echo "user"
|
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 |
|
| Result | Action |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| Found | Read, parse, display summary → Continue |
|
| Found | Read, parse, display summary |
|
||||||
| Not found | ⛔ Run first-time setup ([references/config/first-time-setup.md](references/config/first-time-setup.md)) |
|
| Not found | ⛔ Run [first-time-setup](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.
|
|
||||||
|
|
||||||
Full procedures: [references/workflow.md](references/workflow.md#step-1-pre-check)
|
Full procedures: [references/workflow.md](references/workflow.md#step-1-pre-check)
|
||||||
|
|
||||||
---
|
### Step 2: Analyze
|
||||||
|
|
||||||
### Step 2: Setup & Analyze
|
| Analysis | Output |
|
||||||
|
|----------|--------|
|
||||||
| Analysis | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| Content type | Technical / Tutorial / Methodology / Narrative |
|
| Content type | Technical / Tutorial / Methodology / Narrative |
|
||||||
| Illustration purpose | information / visualization / imagination |
|
| Purpose | information / visualization / imagination |
|
||||||
| Core arguments | 2-5 main points to visualize |
|
| Core arguments | 2-5 main points |
|
||||||
| Visual opportunities | Positions where illustrations add value |
|
| 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)
|
Full procedures: [references/workflow.md](references/workflow.md#step-2-setup--analyze)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 3: Confirm Settings ⚠️
|
### 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 |
|
| Q | Options |
|
||||||
|----------|---------|
|
|---|---------|
|
||||||
| **Q1: Type** ⚠️ | [Recommended], infographic, scene, flowchart, comparison, framework, timeline, mixed |
|
| **Q1: Type** | [Recommended], infographic, scene, flowchart, comparison, framework, timeline, mixed |
|
||||||
| **Q2: Density** ⚠️ | minimal (1-2), balanced (3-5), per-section (Recommended), rich (6+) |
|
| **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 |
|
| **Q3: Style** | [Recommended], minimal-flat, sci-fi, hand-drawn, editorial, scene, Other |
|
||||||
| **Q4: Language** | When article language ≠ EXTEND.md setting |
|
| Q4: Language | When article language ≠ EXTEND.md setting |
|
||||||
|
|
||||||
Full procedures: [references/workflow.md](references/workflow.md#step-3-confirm-settings-)
|
Full procedures: [references/workflow.md](references/workflow.md#step-3-confirm-settings-)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 4: Generate Outline
|
### 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
|
```yaml
|
||||||
## Illustration 1
|
## Illustration 1
|
||||||
**Position**: [section] / [paragraph]
|
**Position**: [section/paragraph]
|
||||||
**Purpose**: [why this helps]
|
**Purpose**: [why]
|
||||||
**Visual Content**: [what to show]
|
**Visual Content**: [what]
|
||||||
**Filename**: 01-infographic-concept-name.png
|
**Filename**: 01-infographic-concept-name.png
|
||||||
```
|
```
|
||||||
|
|
||||||
Full template: [references/workflow.md](references/workflow.md#step-4-generate-outline)
|
Full template: [references/workflow.md](references/workflow.md#step-4-generate-outline)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 5: Generate Images
|
### Step 5: Generate Images
|
||||||
|
|
||||||
1. **Create Prompts**: Follow [references/prompt-construction.md](references/prompt-construction.md)
|
⛔ **BLOCKING: Prompt files MUST be saved before ANY image generation.**
|
||||||
2. **Select Generation Skill**: Check available skills
|
|
||||||
3. **Process References**: Handle `direct`/`style`/`palette` usage
|
1. For each illustration, create a prompt file per [references/prompt-construction.md](references/prompt-construction.md)
|
||||||
4. **Apply Watermark**: If enabled in EXTEND.md
|
2. Save to `prompts/NN-{type}-{slug}.md` with YAML frontmatter
|
||||||
5. **Generate**: Sequential, retry once on failure
|
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)
|
Full procedures: [references/workflow.md](references/workflow.md#step-5-generate-images)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 6: Finalize
|
### Step 6: Finalize
|
||||||
|
|
||||||
**Update Article**: Insert `` after corresponding paragraphs.
|
Insert `` after paragraphs.
|
||||||
|
|
||||||
**Output Summary**:
|
|
||||||
```
|
```
|
||||||
Article Illustration Complete!
|
Article Illustration Complete!
|
||||||
Article: [path] | Type: [type] | Density: [level] | Style: [style]
|
Article: [path] | Type: [type] | Density: [level] | Style: [style]
|
||||||
Images: X/N generated
|
Images: X/N generated
|
||||||
Positions:
|
|
||||||
- 01-xxx.png → After "[Section]"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Output Directory
|
## Output Directory
|
||||||
|
|
||||||
```
|
```
|
||||||
illustrations/{topic-slug}/
|
illustrations/{topic-slug}/
|
||||||
├── source-{slug}.{ext}
|
├── source-{slug}.{ext}
|
||||||
├── references/ # Only if references provided
|
├── references/ # if provided
|
||||||
│ └── NN-ref-{slug}.png
|
|
||||||
├── outline.md
|
├── outline.md
|
||||||
├── prompts/
|
├── prompts/
|
||||||
│ └── illustration-{slug}.md
|
|
||||||
└── NN-{type}-{slug}.png
|
└── NN-{type}-{slug}.png
|
||||||
```
|
```
|
||||||
|
|
||||||
**Slug**: 2-4 word topic in kebab-case.
|
**Slug**: 2-4 words, kebab-case. **Conflict**: append `-YYYYMMDD-HHMMSS`.
|
||||||
**Conflict**: Append `-YYYYMMDD-HHMMSS` if exists.
|
|
||||||
|
|
||||||
## Modification
|
## Modification
|
||||||
|
|
||||||
| Action | Steps |
|
| Action | Steps |
|
||||||
|--------|-------|
|
|--------|-------|
|
||||||
| **Edit** | Update prompt file FIRST → Regenerate → Update reference |
|
| Edit | Update prompt → Regenerate → Update reference |
|
||||||
| **Add** | Identify position → Create prompt → Generate → Update outline → Insert |
|
| Add | Position → Prompt → Generate → Update outline → Insert |
|
||||||
| **Delete** | Delete files → Remove reference → Update outline |
|
| Delete | Delete files → Remove reference → Update outline |
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
| File | Content |
|
| File | Content |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| [references/workflow.md](references/workflow.md) | Detailed workflow procedures |
|
| [references/workflow.md](references/workflow.md) | Detailed procedures |
|
||||||
| [references/usage.md](references/usage.md) | Command syntax and options |
|
| [references/usage.md](references/usage.md) | Command syntax |
|
||||||
| [references/styles.md](references/styles.md) | Style gallery & compatibility |
|
| [references/styles.md](references/styles.md) | Style gallery |
|
||||||
| [references/prompt-construction.md](references/prompt-construction.md) | Prompt templates |
|
| [references/prompt-construction.md](references/prompt-construction.md) | Prompt templates |
|
||||||
| `references/styles/<style>.md` | Full style specifications |
|
| [references/config/first-time-setup.md](references/config/first-time-setup.md) | First-time setup |
|
||||||
| `references/config/preferences-schema.md` | EXTEND.md schema |
|
|
||||||
| `references/config/first-time-setup.md` | First-time setup flow |
|
|
||||||
|
|||||||
@@ -261,10 +261,41 @@ references: # Only if references provided
|
|||||||
|
|
||||||
## Step 5: Generate Images
|
## 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`.
|
**Every illustration MUST have a saved prompt file before generation begins. DO NOT skip this step.**
|
||||||
- **Backup rule**: If prompt file exists, rename to `prompts/illustration-{slug}-backup-YYYYMMDD-HHMMSS.md`
|
|
||||||
|
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**:
|
**CRITICAL - References in Frontmatter**:
|
||||||
- Only add `references` field if files ACTUALLY EXIST in `references/` directory
|
- 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
|
## Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Auto-select all dimensions based on content
|
# Auto-select dimensions based on content
|
||||||
/baoyu-cover-image path/to/article.md
|
/baoyu-cover-image path/to/article.md
|
||||||
|
|
||||||
# Quick mode: skip confirmation, use auto-selection
|
# Quick mode: skip confirmation
|
||||||
/baoyu-cover-image article.md --quick
|
/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 --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
|
||||||
/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
|
# With reference images
|
||||||
/baoyu-cover-image article.md --ref style-ref.png
|
/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
|
## Options
|
||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `--type <name>` | Cover type: hero, conceptual, typography, metaphor, scene, minimal |
|
| `--type <name>` | hero, conceptual, typography, metaphor, scene, minimal |
|
||||||
| `--palette <name>` | Color palette: warm, elegant, cool, dark, earth, vivid, pastel, mono, retro |
|
| `--palette <name>` | warm, elegant, cool, dark, earth, vivid, pastel, mono, retro |
|
||||||
| `--rendering <name>` | Rendering style: flat-vector, hand-drawn, painterly, digital, pixel, chalk |
|
| `--rendering <name>` | flat-vector, hand-drawn, painterly, digital, pixel, chalk |
|
||||||
| `--style <name>` | Preset shorthand (expands to palette + rendering, see [Style Presets](references/style-presets.md)) |
|
| `--style <name>` | Preset shorthand (see [Style Presets](references/style-presets.md)) |
|
||||||
| `--text <level>` | Text density: none, title-only, title-subtitle, text-rich |
|
| `--text <level>` | none, title-only, title-subtitle, text-rich |
|
||||||
| `--mood <level>` | Emotional intensity: subtle, balanced, bold |
|
| `--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 |
|
| `--aspect <ratio>` | 16:9 (default), 2.35:1, 4:3, 3:2, 1:1, 3:4 |
|
||||||
| `--lang <code>` | Title language (en, zh, ja, etc.) |
|
| `--lang <code>` | Title language (en, zh, ja, etc.) |
|
||||||
| `--no-title` | Alias for `--text none` |
|
| `--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 |
|
| `--ref <files...>` | Reference images for style/composition guidance |
|
||||||
|
|
||||||
## Five Dimensions
|
## Five Dimensions
|
||||||
|
|
||||||
| Dimension | Controls | Values | Default |
|
| Dimension | Values | Default |
|
||||||
|-----------|----------|--------|---------|
|
|-----------|--------|---------|
|
||||||
| **Type** | Visual composition, information structure | hero, conceptual, typography, metaphor, scene, minimal | auto |
|
| **Type** | hero, conceptual, typography, metaphor, scene, minimal | auto |
|
||||||
| **Palette** | Colors, color scheme, decorative hints | warm, elegant, cool, dark, earth, vivid, pastel, mono, retro | auto |
|
| **Palette** | 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 |
|
| **Rendering** | flat-vector, hand-drawn, painterly, digital, pixel, chalk | auto |
|
||||||
| **Text** | Text density, information hierarchy | none, title-only, title-subtitle, text-rich | title-only |
|
| **Text** | none, title-only, title-subtitle, text-rich | title-only |
|
||||||
| **Mood** | Emotional intensity, visual weight | subtle, balanced, bold | balanced |
|
| **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 |
|
**Types**: hero, conceptual, typography, metaphor, scene, minimal
|
||||||
|------|-------------|----------|
|
→ Details: [references/types.md](references/types.md)
|
||||||
| `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 |
|
|
||||||
|
|
||||||
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 |
|
**Text Levels**: none (pure visual) | title-only (default) | title-subtitle | text-rich (with tags)
|
||||||
|---------|------|----------------|
|
→ Details: [references/dimensions/text.md](references/dimensions/text.md)
|
||||||
| `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 |
|
|
||||||
|
|
||||||
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
|
**Fonts**: clean (sans-serif) | handwritten | serif | display (bold decorative)
|
||||||
|
→ Details: [references/dimensions/font.md](references/dimensions/font.md)
|
||||||
| 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
|
|
||||||
|
|
||||||
## File Structure
|
## File Structure
|
||||||
|
|
||||||
Output directory depends on `default_output_dir` preference:
|
Output directory per `default_output_dir` preference:
|
||||||
|
- `same-dir`: `{article-dir}/`
|
||||||
| Preference | Output Path |
|
- `imgs-subdir`: `{article-dir}/imgs/`
|
||||||
|------------|-------------|
|
- `independent` (default): `cover-image/{topic-slug}/`
|
||||||
| `same-dir` | `{article-dir}/` |
|
|
||||||
| `imgs-subdir` | `{article-dir}/imgs/` |
|
|
||||||
| `independent` (default) | `cover-image/{topic-slug}/` |
|
|
||||||
| Pasted content | `cover-image/{topic-slug}/` (always) |
|
|
||||||
|
|
||||||
```
|
```
|
||||||
<output-dir>/
|
<output-dir>/
|
||||||
├── source-{slug}.{ext} # Source files (text, images, etc.)
|
├── source-{slug}.{ext} # Source files
|
||||||
├── refs/ # Reference images (if provided)
|
├── refs/ # Reference images (if provided)
|
||||||
│ ├── ref-01-{slug}.{ext}
|
│ ├── ref-01-{slug}.{ext}
|
||||||
│ ├── ref-01-{slug}.md # Description file (optional)
|
│ └── ref-01-{slug}.md # Description file
|
||||||
│ ├── ref-02-{slug}.{ext}
|
|
||||||
│ ├── ref-02-{slug}.md # Description file (optional)
|
|
||||||
│ └── extracted-style.md # Verbally extracted style (if no file path)
|
|
||||||
├── prompts/cover.md # Generation prompt
|
├── prompts/cover.md # Generation prompt
|
||||||
└── cover.png # Output image
|
└── cover.png # Output image
|
||||||
```
|
```
|
||||||
|
|
||||||
**Slug**: Extract main topic (2-4 words, kebab-case). Example: "The Future of AI" → `future-of-ai`
|
**Slug**: 2-4 words, kebab-case. Conflict: append `-YYYYMMDD-HHMMSS`
|
||||||
**Conflict**: If directory exists, append timestamp: `{topic-slug}-YYYYMMDD-HHMMSS`
|
|
||||||
**Source Files**: Copy all sources with naming `source-{slug}.{ext}` (multiple supported)
|
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
@@ -168,20 +106,10 @@ Output directory depends on `default_output_dir` preference:
|
|||||||
```
|
```
|
||||||
Cover Image Progress:
|
Cover Image Progress:
|
||||||
- [ ] Step 0: Check preferences (EXTEND.md) ⛔ BLOCKING
|
- [ ] Step 0: Check preferences (EXTEND.md) ⛔ BLOCKING
|
||||||
- [ ] Found → load preferences → continue
|
- [ ] Step 1: Analyze content + save refs + determine output dir
|
||||||
- [ ] Not found → run first-time setup → MUST complete before Step 1
|
- [ ] Step 2: Confirm options (6 dimensions) ⚠️ unless --quick
|
||||||
- [ ] 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 3: Create prompt
|
- [ ] 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
|
- [ ] 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
|
- [ ] Step 5: Completion report
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -189,240 +117,66 @@ Cover Image Progress:
|
|||||||
|
|
||||||
```
|
```
|
||||||
Input → [Step 0: Preferences] ─┬─ Found → Continue
|
Input → [Step 0: Preferences] ─┬─ Found → Continue
|
||||||
│
|
└─ Not found → First-Time Setup ⛔ BLOCKING → Save EXTEND.md → Continue
|
||||||
└─ Not found → First-Time Setup ⛔ BLOCKING
|
|
||||||
│
|
|
||||||
└─ Complete setup → Save EXTEND.md → Continue
|
|
||||||
│
|
|
||||||
┌───────────────────────────────────────────────────────────────────────────┘
|
|
||||||
↓
|
↓
|
||||||
Analyze + Save Refs → [Output Dir ⚠️] → [Confirm: 5 Dimensions] → Prompt → Generate → Complete
|
Analyze + Save Refs → [Output Dir] → [Confirm: 6 Dimensions] → Prompt → Generate → Complete
|
||||||
↓
|
↓
|
||||||
(skip if --quick or all specified)
|
(skip if --quick or all specified)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 0: Load Preferences (EXTEND.md) ⛔ BLOCKING
|
### Step 0: Load Preferences ⛔ 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):
|
|
||||||
|
|
||||||
|
Check EXTEND.md existence (priority: project → user):
|
||||||
```bash
|
```bash
|
||||||
# Check project-level first
|
|
||||||
test -f .baoyu-skills/baoyu-cover-image/EXTEND.md && echo "project"
|
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"
|
test -f "$HOME/.baoyu-skills/baoyu-cover-image/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
| Result | Action |
|
| Result | Action |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| Found | Read, parse, display preferences summary → Continue to Step 1 |
|
| Found | Load, display summary → Continue |
|
||||||
| 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 |
|
| Not found | ⛔ Run first-time setup ([references/config/first-time-setup.md](references/config/first-time-setup.md)) → Save → Continue |
|
||||||
|
|
||||||
**Preferences Summary** (when found):
|
**CRITICAL**: If not found, complete setup BEFORE any other steps or questions.
|
||||||
|
|
||||||
```
|
|
||||||
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)
|
|
||||||
|
|
||||||
### Step 1: Analyze Content
|
### Step 1: Analyze Content
|
||||||
|
|
||||||
**1.0 Detect & Save Reference Images** ⚠️ REQUIRED if images provided
|
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`)
|
||||||
Check if user provided reference images. Handle based on input type:
|
3. **Analyze content**: topic, tone, keywords, visual metaphors
|
||||||
|
4. **Deep analyze references** ⚠️: Extract specific, concrete elements (see reference-images.md)
|
||||||
| Input Type | Action |
|
5. **Detect language**: Compare source, user input, EXTEND.md preference
|
||||||
|------------|--------|
|
6. **Determine output directory**: Per File Structure rules
|
||||||
| 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
|
|
||||||
|
|
||||||
### Step 2: Confirm Options ⚠️
|
### Step 2: Confirm Options ⚠️
|
||||||
|
|
||||||
Validate all 5 dimensions + aspect ratio. Full confirmation flow: [references/workflow/confirm-options.md](references/workflow/confirm-options.md)
|
Full confirmation flow: [references/workflow/confirm-options.md](references/workflow/confirm-options.md)
|
||||||
|
|
||||||
**Skip Conditions**:
|
|
||||||
|
|
||||||
| Condition | Skipped | Still Asked |
|
| Condition | Skipped | Still Asked |
|
||||||
|-----------|---------|-------------|
|
|-----------|---------|-------------|
|
||||||
| `--quick` or `quick_mode: true` | 5 dimensions | Aspect ratio (unless `--aspect`) |
|
| `--quick` or `quick_mode: true` | 6 dimensions | Aspect ratio (unless `--aspect`) |
|
||||||
| All 5 + `--aspect` specified | All | None |
|
| All 6 + `--aspect` specified | All | None |
|
||||||
|
|
||||||
### Step 3: Create Prompt
|
### 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**:
|
**Reference elements in body** MUST be detailed, prefixed with "MUST"/"REQUIRED", with integration approach.
|
||||||
|
|
||||||
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 | — |
|
|
||||||
|
|
||||||
### Step 4: Generate Image
|
### Step 4: Generate Image
|
||||||
|
|
||||||
**4.1 Backup existing** `cover.png` → `cover-backup-YYYYMMDD-HHMMSS.png` (if regenerating)
|
1. **Backup existing** `cover.png` if regenerating
|
||||||
|
2. **Check image generation skills**; if multiple, ask preference
|
||||||
**4.2 Check available image generation skills**; if multiple, ask user preference
|
3. **Process references** from prompt frontmatter:
|
||||||
|
- `direct` usage → pass via `--ref` (use ref-capable backend)
|
||||||
**4.3 Process References** ⚠️ REQUIRED if references in frontmatter
|
- `style`/`palette` → extract traits, append to prompt
|
||||||
|
4. **Generate**: Call skill with prompt file, output path, aspect ratio
|
||||||
**Read `references` from prompt frontmatter** and process each entry:
|
5. On failure: auto-retry once
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
### Step 5: Completion Report
|
### Step 5: Completion Report
|
||||||
|
|
||||||
@@ -431,67 +185,49 @@ Cover Generated!
|
|||||||
|
|
||||||
Topic: [topic]
|
Topic: [topic]
|
||||||
Type: [type] | Palette: [palette] | Rendering: [rendering]
|
Type: [type] | Palette: [palette] | Rendering: [rendering]
|
||||||
Text: [text] | Mood: [mood] | Aspect: [ratio]
|
Text: [text] | Mood: [mood] | Font: [font] | Aspect: [ratio]
|
||||||
Title: [title text or "visual only"]
|
Title: [title or "visual only"]
|
||||||
Language: [lang] | Watermark: [enabled/disabled]
|
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]
|
Location: [directory path]
|
||||||
|
|
||||||
Files:
|
Files:
|
||||||
✓ source-{slug}.{ext}
|
✓ 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
|
✓ prompts/cover.md
|
||||||
✓ cover.png
|
✓ cover.png
|
||||||
[✓ cover-backup-{timestamp}.png (if regenerated)]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Image Modification
|
## Image Modification
|
||||||
|
|
||||||
| Action | Steps |
|
| Action | Steps |
|
||||||
|--------|-------|
|
|--------|-------|
|
||||||
| **Regenerate** | Backup existing → **Update prompt file FIRST** → Regenerate with same settings |
|
| **Regenerate** | Backup → Update prompt file FIRST → Regenerate |
|
||||||
| **Change dimension** | Backup existing → Confirm new value → **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
|
Custom configurations via EXTEND.md. See **Step 0** for paths.
|
||||||
- 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
|
|
||||||
|
|
||||||
### Composition Principles
|
Supports: Watermark | Preferred dimensions | Default aspect/output | Quick mode | Custom palettes | Language
|
||||||
|
|
||||||
- **Generous whitespace**: 40-60% breathing room; avoid cluttered layouts
|
Schema: [references/config/preferences-schema.md](references/config/preferences-schema.md)
|
||||||
- **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
|
|
||||||
|
|
||||||
## References
|
## 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/)
|
**Palettes**: [references/palettes/](references/palettes/)
|
||||||
**Renderings**: [references/renderings/](references/renderings/)
|
**Renderings**: [references/renderings/](references/renderings/)
|
||||||
|
**Types**: [references/types.md](references/types.md)
|
||||||
**Auto-Selection**: [references/auto-selection.md](references/auto-selection.md)
|
**Auto-Selection**: [references/auto-selection.md](references/auto-selection.md)
|
||||||
**Style Presets**: [references/style-presets.md](references/style-presets.md)
|
**Style Presets**: [references/style-presets.md](references/style-presets.md)
|
||||||
**Compatibility**: [references/compatibility.md](references/compatibility.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)
|
**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)
|
**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` |
|
| Launch, announcement, promotion, event, gaming, entertainment | `bold` |
|
||||||
|
|
||||||
Default: `balanced`
|
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 | ✓✓ | ✓✓ | ✓ |
|
| metaphor | ✓✓ | ✓✓ | ✓ |
|
||||||
| scene | ✓✓ | ✓✓ | ✓ |
|
| scene | ✓✓ | ✓✓ | ✓ |
|
||||||
| minimal | ✓✓ | ✓✓ | ✗ |
|
| 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` |
|
| `vector-illustration` | `retro` | `flat-vector` |
|
||||||
| `vintage` | `retro` | `hand-drawn` |
|
| `vintage` | `retro` | `hand-drawn` |
|
||||||
| `warm` | `warm` | `hand-drawn` |
|
| `warm` | `warm` | `hand-drawn` |
|
||||||
|
| `warm-flat` | `warm` | `flat-vector` |
|
||||||
| `watercolor` | `earth` | `painterly` |
|
| `watercolor` | `earth` | `painterly` |
|
||||||
|
|
||||||
## Override Examples
|
## Override Examples
|
||||||
|
|||||||
@@ -2,22 +2,22 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Validate all 5 dimensions + aspect ratio.
|
Validate all 6 dimensions + aspect ratio.
|
||||||
|
|
||||||
## Skip Conditions
|
## Skip Conditions
|
||||||
|
|
||||||
| Condition | Skipped Questions | Still Asked |
|
| Condition | Skipped Questions | Still Asked |
|
||||||
|-----------|-------------------|-------------|
|
|-----------|-------------------|-------------|
|
||||||
| `--quick` flag | Type, Palette, Rendering, Text, Mood | **Aspect Ratio** (unless `--aspect` specified) |
|
| `--quick` flag | Type, Palette, Rendering, Text, Mood, Font | **Aspect Ratio** (unless `--aspect` specified) |
|
||||||
| All 5 dimensions + `--aspect` specified | All | None |
|
| All 6 dimensions + `--aspect` specified | All | None |
|
||||||
| `quick_mode: true` in EXTEND.md | Type, Palette, Rendering, Text, Mood | **Aspect Ratio** (unless `--aspect` specified) |
|
| `quick_mode: true` in EXTEND.md | Type, Palette, Rendering, Text, Mood, Font | **Aspect Ratio** (unless `--aspect` specified) |
|
||||||
| Otherwise | None | All 6 questions |
|
| 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.
|
**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
|
## Quick Mode Output
|
||||||
|
|
||||||
When skipping 5 dimensions:
|
When skipping 6 dimensions:
|
||||||
|
|
||||||
```
|
```
|
||||||
Quick Mode: Auto-selected dimensions
|
Quick Mode: Auto-selected dimensions
|
||||||
@@ -26,8 +26,9 @@ Quick Mode: Auto-selected dimensions
|
|||||||
• Rendering: [rendering] ([reason])
|
• Rendering: [rendering] ([reason])
|
||||||
• Text: [text] ([reason])
|
• Text: [text] ([reason])
|
||||||
• Mood: [mood] ([reason])
|
• Mood: [mood] ([reason])
|
||||||
|
• Font: [font] ([reason])
|
||||||
|
|
||||||
[Then ask Question 6: Aspect Ratio]
|
[Then ask Question 7: Aspect Ratio]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Confirmation Flow
|
## Confirmation Flow
|
||||||
@@ -91,7 +92,26 @@ options:
|
|||||||
description: "Polished, precise, subtle gradients"
|
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".
|
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]
|
Type: [confirmed type]
|
||||||
Palette: [confirmed palette]
|
Palette: [confirmed palette]
|
||||||
Rendering: [confirmed rendering]
|
Rendering: [confirmed rendering]
|
||||||
|
Font: [confirmed font]
|
||||||
Text level: [confirmed text level]
|
Text level: [confirmed text level]
|
||||||
Mood: [confirmed mood]
|
Mood: [confirmed mood]
|
||||||
Aspect ratio: [confirmed ratio]
|
Aspect ratio: [confirmed ratio]
|
||||||
@@ -44,6 +45,13 @@ Language: [confirmed language]
|
|||||||
- balanced: "Use medium contrast, normal saturation, balanced visual weight"
|
- balanced: "Use medium contrast, normal saturation, balanced visual weight"
|
||||||
- bold: "Use high contrast, vivid saturated colors, heavy visual weight, dynamic energy"
|
- 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
|
# Composition
|
||||||
Type composition:
|
Type composition:
|
||||||
- [Type-specific layout and structure]
|
- [Type-specific layout and structure]
|
||||||
@@ -60,9 +68,15 @@ Palette notes: [key characteristics from palette definition]
|
|||||||
|
|
||||||
[Watermark section if enabled]
|
[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
|
## Content-Driven Design
|
||||||
|
|
||||||
- Article title and summary inform the visual metaphor choice
|
- 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)
|
- **Color mood**: Palette characteristics (without specific colors)
|
||||||
- **Elements**: Key visual elements and symbols used
|
- **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):
|
**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.
|
||||||
- Pass ref images directly via `--ref` parameter
|
|
||||||
- Add brief style note in prompt:
|
**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
|
# Reference Style — MUST INCORPORATE
|
||||||
Follow the visual style of the attached reference image(s):
|
|
||||||
- [1-2 sentence style summary from analysis]
|
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`**:
|
**Key rules**:
|
||||||
- Embed detailed text description in prompt:
|
- 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**
|
||||||
# Reference Style (Text Description)
|
- After generation, verify reference elements are visible; if not, strengthen and regenerate
|
||||||
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.
|
|
||||||
```
|
|
||||||
|
|
||||||
**If style/palette extracted verbally (NO file saved)**:
|
**If style/palette extracted verbally (NO file saved)**:
|
||||||
- DO NOT add references metadata to prompt
|
- 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):
|
CRITICAL: Apply these specific visual elements extracted from the reference images.
|
||||||
- Primary: #E8756D coral
|
|
||||||
- Secondary: #7ECFC0 mint
|
|
||||||
...
|
|
||||||
|
|
||||||
STYLE (from reference):
|
## REQUIRED elements:
|
||||||
- Clean lines, minimal shadows
|
- [Same detailed bullet format as above]
|
||||||
- Gradient backgrounds
|
|
||||||
- Flat vector icons
|
## Integration approach:
|
||||||
...
|
[Same spatial layout instruction]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Reference Analysis Template
|
### 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 |
|
| Aspect | Analysis Points | Good Example | Bad Example |
|
||||||
|--------|-----------------|
|
|--------|-----------------|--------------|-------------|
|
||||||
| **Rendering** | Line quality (clean/sketchy/painted), texture presence, depth treatment |
|
| **Brand elements** | Logos, wordmarks, distinctive typography | "Logo 'm' formed by 3 vertical lines" | "Has a logo" |
|
||||||
| **Composition** | Element placement, whitespace ratio, visual flow |
|
| **Signature patterns** | Unique motifs, textures, geometric patterns | "Woven curves forming diamond grid" | "Has patterns" |
|
||||||
| **Typography** | Font style, text placement, hierarchy (if present) |
|
| **Colors** | Exact hex values or close approximations | "#2D4A3E dark teal, #F5F0E0 cream" | "Dark and light" |
|
||||||
| **Elements** | Icon vocabulary, decorative motifs, character style |
|
| **Layout** | Spatial zones, banner placement, proportions | "Bottom 30% is dark banner with branding" | "Has a banner" |
|
||||||
| **Mood** | Contrast level, saturation, visual weight |
|
| **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 │
|
│ 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
|
## Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts <url>
|
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> -o output.md
|
||||||
|
npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --download-media
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --json
|
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 |
|
| `<url>` | Tweet or article URL |
|
||||||
| `-o <path>` | Output path |
|
| `-o <path>` | Output path |
|
||||||
| `--json` | JSON output |
|
| `--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 |
|
| `--login` | Refresh cookies only |
|
||||||
|
|
||||||
## Supported URLs
|
## Supported URLs
|
||||||
@@ -124,9 +161,10 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --json
|
|||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
---
|
---
|
||||||
url: https://x.com/user/status/123
|
url: "https://x.com/user/status/123"
|
||||||
author: "Name (@user)"
|
author: "Name (@user)"
|
||||||
tweet_count: 3
|
tweetCount: 3
|
||||||
|
coverImage: "https://pbs.twimg.com/media/example.jpg"
|
||||||
---
|
---
|
||||||
|
|
||||||
Content...
|
Content...
|
||||||
@@ -134,6 +172,32 @@ Content...
|
|||||||
|
|
||||||
**File structure**: `x-to-markdown/{username}/{tweet-id}.md`
|
**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
|
## Authentication
|
||||||
|
|
||||||
1. **Environment variables** (preferred): `X_AUTH_TOKEN`, `X_CT0`
|
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 { fetchXArticle } from "./graphql.js";
|
||||||
import { formatArticleMarkdown } from "./markdown.js";
|
import { formatArticleMarkdown } from "./markdown.js";
|
||||||
|
import { localizeMarkdownMedia, type LocalizeMarkdownMediaResult } from "./media-localizer.js";
|
||||||
import { hasRequiredXCookies, loadXCookies, refreshXCookies } from "./cookies.js";
|
import { hasRequiredXCookies, loadXCookies, refreshXCookies } from "./cookies.js";
|
||||||
import { resolveXToMarkdownConsentPath } from "./paths.js";
|
import { resolveXToMarkdownConsentPath } from "./paths.js";
|
||||||
import { tweetToMarkdown } from "./tweet-to-markdown.js";
|
import { tweetToMarkdown } from "./tweet-to-markdown.js";
|
||||||
@@ -15,6 +16,7 @@ type CliArgs = {
|
|||||||
output: string | null;
|
output: string | null;
|
||||||
json: boolean;
|
json: boolean;
|
||||||
login: boolean;
|
login: boolean;
|
||||||
|
downloadMedia: boolean;
|
||||||
help: boolean;
|
help: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,6 +40,7 @@ Usage:
|
|||||||
Options:
|
Options:
|
||||||
--output <path>, -o Output path (file or dir). Default: ./x-to-markdown/<slug>/
|
--output <path>, -o Output path (file or dir). Default: ./x-to-markdown/<slug>/
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
|
--download-media Download images/videos to local ./imgs and ./videos next to markdown
|
||||||
--login Refresh cookies only, then exit
|
--login Refresh cookies only, then exit
|
||||||
--help, -h Show help
|
--help, -h Show help
|
||||||
|
|
||||||
@@ -45,6 +48,7 @@ Examples:
|
|||||||
${cmd} https://x.com/username/status/1234567890
|
${cmd} https://x.com/username/status/1234567890
|
||||||
${cmd} https://x.com/i/article/1234567890 -o ./article.md
|
${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 -o ./out/
|
||||||
|
${cmd} https://x.com/username/status/1234567890 --download-media
|
||||||
${cmd} https://x.com/username/status/1234567890 --json | jq -r '.markdownPath'
|
${cmd} https://x.com/username/status/1234567890 --json | jq -r '.markdownPath'
|
||||||
${cmd} --login
|
${cmd} --login
|
||||||
`);
|
`);
|
||||||
@@ -57,6 +61,7 @@ function parseArgs(argv: string[]): CliArgs {
|
|||||||
output: null,
|
output: null,
|
||||||
json: false,
|
json: false,
|
||||||
login: false,
|
login: false,
|
||||||
|
downloadMedia: false,
|
||||||
help: false,
|
help: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,6 +85,11 @@ function parseArgs(argv: string[]): CliArgs {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (a === "--download-media") {
|
||||||
|
out.downloadMedia = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (a === "--url") {
|
if (a === "--url") {
|
||||||
const v = argv[++i];
|
const v = argv[++i];
|
||||||
if (!v) throw new Error("Missing value for --url");
|
if (!v) throw new Error("Missing value for --url");
|
||||||
@@ -345,16 +355,17 @@ async function convertArticleToMarkdown(
|
|||||||
|
|
||||||
log(`[x-to-markdown] Fetching article ${articleId}...`);
|
log(`[x-to-markdown] Fetching article ${articleId}...`);
|
||||||
const article = await fetchXArticle(articleId, cookieMap, false);
|
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 title = typeof (article as any)?.title === "string" ? String((article as any).title).trim() : "";
|
||||||
const meta = formatMetaMarkdown({
|
const meta = formatMetaMarkdown({
|
||||||
url: `https://x.com/i/article/${articleId}`,
|
url: `https://x.com/i/article/${articleId}`,
|
||||||
requested_url: inputUrl,
|
requestedUrl: inputUrl,
|
||||||
title: title || null,
|
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> {
|
async function main(): Promise<void> {
|
||||||
@@ -385,11 +396,24 @@ async function main(): Promise<void> {
|
|||||||
const kind = articleId ? ("article" as const) : ("tweet" as const);
|
const kind = articleId ? ("article" as const) : ("tweet" as const);
|
||||||
const { outputDir, markdownPath, slug } = await resolveOutputPath(normalizedUrl, kind, args.output, log);
|
const { outputDir, markdownPath, slug } = await resolveOutputPath(normalizedUrl, kind, args.output, log);
|
||||||
|
|
||||||
const markdown =
|
let markdown =
|
||||||
kind === "article" && articleId
|
kind === "article" && articleId
|
||||||
? await convertArticleToMarkdown(normalizedUrl, articleId, log)
|
? await convertArticleToMarkdown(normalizedUrl, articleId, log)
|
||||||
: await tweetToMarkdown(normalizedUrl, { 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");
|
await writeFile(markdownPath, markdown, "utf8");
|
||||||
log(`[x-to-markdown] Saved: ${markdownPath}`);
|
log(`[x-to-markdown] Saved: ${markdownPath}`);
|
||||||
|
|
||||||
@@ -398,11 +422,16 @@ async function main(): Promise<void> {
|
|||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
url: articleId ? `https://x.com/i/article/${articleId}` : normalizedUrl,
|
url: articleId ? `https://x.com/i/article/${articleId}` : normalizedUrl,
|
||||||
requested_url: normalizedUrl,
|
requestedUrl: normalizedUrl,
|
||||||
type: kind,
|
type: kind,
|
||||||
slug,
|
slug,
|
||||||
outputDir,
|
outputDir,
|
||||||
markdownPath,
|
markdownPath,
|
||||||
|
downloadMedia: args.downloadMedia,
|
||||||
|
downloadedImages: mediaResult?.downloadedImages ?? 0,
|
||||||
|
downloadedVideos: mediaResult?.downloadedVideos ?? 0,
|
||||||
|
imageDir: mediaResult?.imageDir ?? null,
|
||||||
|
videoDir: mediaResult?.videoDir ?? null,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
|
|||||||
@@ -257,10 +257,15 @@ function renderContentBlocks(
|
|||||||
return lines;
|
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);
|
const candidate = coerceArticleEntity(article);
|
||||||
if (!candidate) {
|
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[] = [];
|
const lines: string[] = [];
|
||||||
@@ -271,10 +276,8 @@ export function formatArticleMarkdown(article: unknown): string {
|
|||||||
lines.push(`# ${title}`);
|
lines.push(`# ${title}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const coverUrl = resolveMediaUrl(candidate.cover_media?.media_info);
|
const coverUrl = resolveMediaUrl(candidate.cover_media?.media_info) ?? null;
|
||||||
if (coverUrl) {
|
if (coverUrl) {
|
||||||
if (lines.length > 0) lines.push("");
|
|
||||||
lines.push(``);
|
|
||||||
usedUrls.add(coverUrl);
|
usedUrls.add(coverUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +297,7 @@ export function formatArticleMarkdown(article: unknown): string {
|
|||||||
lines.push(candidate.preview_text.trim());
|
lines.push(candidate.preview_text.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaUrls = collectMediaUrls(candidate, usedUrls, coverUrl);
|
const mediaUrls = collectMediaUrls(candidate, usedUrls, coverUrl ?? undefined);
|
||||||
if (mediaUrls.length > 0) {
|
if (mediaUrls.length > 0) {
|
||||||
lines.push("", "## Media", "");
|
lines.push("", "## Media", "");
|
||||||
for (const url of mediaUrls) {
|
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 requestedUrl = normalizedUrl || buildTweetUrl(username, tweetId) || inputUrl.trim();
|
||||||
const rootUrl = buildTweetUrl(username, thread.rootId ?? tweetId) ?? requestedUrl;
|
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);
|
const articleEntity = await resolveArticleEntityFromTweet(firstTweet, cookieMap);
|
||||||
|
let coverImage: string | null = null;
|
||||||
let remainingTweets = tweets;
|
let remainingTweets = tweets;
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
if (articleEntity) {
|
if (articleEntity) {
|
||||||
const articleMarkdown = formatArticleMarkdown(articleEntity).trimEnd();
|
const articleResult = formatArticleMarkdown(articleEntity);
|
||||||
|
coverImage = articleResult.coverUrl;
|
||||||
|
const articleMarkdown = articleResult.markdown.trimEnd();
|
||||||
if (articleMarkdown) {
|
if (articleMarkdown) {
|
||||||
parts.push(articleMarkdown);
|
parts.push(articleMarkdown);
|
||||||
const firstTweetText = extractTweetText(firstTweet);
|
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) {
|
if (remainingTweets.length > 0) {
|
||||||
const hasArticle = parts.length > 1;
|
const hasArticle = parts.length > 1;
|
||||||
if (hasArticle) {
|
if (hasArticle) {
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ Check for YAML frontmatter (`---` block). Create if missing.
|
|||||||
| `title` | See Step 4 |
|
| `title` | See Step 4 |
|
||||||
| `slug` | Infer from file path (e.g., `posts/2026/01/10/vibe-coding/` → `vibe-coding`) or generate from title |
|
| `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) |
|
| `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
|
### Step 4: Title Handling
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ test -f "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "user"
|
|||||||
│ Not found │ Use defaults │
|
│ 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
|
## Usage
|
||||||
|
|
||||||
@@ -58,9 +60,12 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --quali
|
|||||||
# From prompt files
|
# From prompt files
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --promptfiles system.md content.md --image out.png
|
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
|
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
|
# Specific provider
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider openai
|
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider openai
|
||||||
|
|
||||||
@@ -76,12 +81,12 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "一只可爱的猫" --image ou
|
|||||||
| `--promptfiles <files...>` | Read prompt from files (concatenated) |
|
| `--promptfiles <files...>` | Read prompt from files (concatenated) |
|
||||||
| `--image <path>` | Output image path (required) |
|
| `--image <path>` | Output image path (required) |
|
||||||
| `--provider google\|openai\|dashscope` | Force provider (default: google) |
|
| `--provider google\|openai\|dashscope` | Force provider (default: google) |
|
||||||
| `--model <id>`, `-m` | Model ID |
|
| `--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`) |
|
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
||||||
| `--size <WxH>` | Size (e.g., `1024x1024`) |
|
| `--size <WxH>` | Size (e.g., `1024x1024`) |
|
||||||
| `--quality normal\|2k` | Quality preset (default: 2k) |
|
| `--quality normal\|2k` | Quality preset (default: 2k) |
|
||||||
| `--imageSize 1K\|2K\|4K` | Image size for Google (default: from quality) |
|
| `--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 |
|
| `--n <count>` | Number of images |
|
||||||
| `--json` | JSON output |
|
| `--json` | JSON output |
|
||||||
|
|
||||||
@@ -99,13 +104,14 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "一只可爱的猫" --image ou
|
|||||||
| `GOOGLE_BASE_URL` | Custom Google endpoint |
|
| `GOOGLE_BASE_URL` | Custom Google endpoint |
|
||||||
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint |
|
| `DASHSCOPE_BASE_URL` | Custom DashScope 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`
|
||||||
|
|
||||||
## Provider Selection
|
## Provider Selection
|
||||||
|
|
||||||
1. `--provider` specified → use it
|
1. `--ref` provided + no `--provider` → auto-select Google first, then OpenAI
|
||||||
2. Only one API key available → use that provider
|
2. `--provider` specified → use it (if `--ref`, must be `google` or `openai`)
|
||||||
3. Multiple available → default to Google
|
3. Only one API key available → use that provider
|
||||||
|
4. Multiple available → default to Google
|
||||||
|
|
||||||
## Quality Presets
|
## Quality Presets
|
||||||
|
|
||||||
@@ -155,7 +161,7 @@ Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`
|
|||||||
- Missing API key → error with setup instructions
|
- Missing API key → error with setup instructions
|
||||||
- Generation failure → auto-retry once
|
- Generation failure → auto-retry once
|
||||||
- Invalid aspect ratio → warning, proceed with default
|
- 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
|
## Extension Support
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
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|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"
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|
||||||
|
## 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"
|
||||||
|
---
|
||||||
|
```
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
import type { CliArgs, Provider } from "./types";
|
import type { CliArgs, Provider, ExtendConfig } from "./types";
|
||||||
|
|
||||||
function printUsage(): void {
|
function printUsage(): void {
|
||||||
console.log(`Usage:
|
console.log(`Usage:
|
||||||
@@ -20,7 +20,7 @@ Options:
|
|||||||
--size <WxH> Size (e.g., 1024x1024)
|
--size <WxH> Size (e.g., 1024x1024)
|
||||||
--quality normal|2k Quality preset (default: 2k)
|
--quality normal|2k Quality preset (default: 2k)
|
||||||
--imageSize 1K|2K|4K Image size for Google (default: from quality)
|
--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)
|
--n <count> Number of images (default: 1)
|
||||||
--json JSON output
|
--json JSON output
|
||||||
-h, --help Show help
|
-h, --help Show help
|
||||||
@@ -37,7 +37,7 @@ Environment variables:
|
|||||||
GOOGLE_BASE_URL Custom Google endpoint
|
GOOGLE_BASE_URL Custom Google endpoint
|
||||||
DASHSCOPE_BASE_URL Custom DashScope endpoint
|
DASHSCOPE_BASE_URL Custom DashScope 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 {
|
function parseArgs(argv: string[]): CliArgs {
|
||||||
@@ -49,7 +49,7 @@ function parseArgs(argv: string[]): CliArgs {
|
|||||||
model: null,
|
model: null,
|
||||||
aspectRatio: null,
|
aspectRatio: null,
|
||||||
size: null,
|
size: null,
|
||||||
quality: "2k",
|
quality: null,
|
||||||
imageSize: null,
|
imageSize: null,
|
||||||
referenceImages: [],
|
referenceImages: [],
|
||||||
n: 1,
|
n: 1,
|
||||||
@@ -215,6 +215,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 };
|
||||||
|
currentKey = "default_model";
|
||||||
|
} else if (currentKey === "default_model" && (key === "google" || key === "openai" || key === "dashscope")) {
|
||||||
|
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> {
|
async function readPromptFromFiles(files: string[]): Promise<string> {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
@@ -242,12 +323,26 @@ function normalizeOutputImagePath(p: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function detectProvider(args: CliArgs): Provider {
|
function detectProvider(args: CliArgs): Provider {
|
||||||
|
if (args.referenceImages.length > 0 && args.provider && args.provider !== "google" && args.provider !== "openai") {
|
||||||
|
throw new Error(
|
||||||
|
"Reference images require a ref-capable provider. Use --provider google (Gemini multimodal) or --provider openai (GPT Image edits)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (args.provider) return args.provider;
|
if (args.provider) return args.provider;
|
||||||
|
|
||||||
const hasGoogle = !!(process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY);
|
const hasGoogle = !!(process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY);
|
||||||
const hasOpenai = !!process.env.OPENAI_API_KEY;
|
const hasOpenai = !!process.env.OPENAI_API_KEY;
|
||||||
const hasDashscope = !!process.env.DASHSCOPE_API_KEY;
|
const hasDashscope = !!process.env.DASHSCOPE_API_KEY;
|
||||||
|
|
||||||
|
if (args.referenceImages.length > 0) {
|
||||||
|
if (hasGoogle) return "google";
|
||||||
|
if (hasOpenai) return "openai";
|
||||||
|
throw new Error(
|
||||||
|
"Reference images require Google or OpenAI. Set GOOGLE_API_KEY/GEMINI_API_KEY or OPENAI_API_KEY, or remove --ref."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const available = [hasGoogle && "google", hasOpenai && "openai", hasDashscope && "dashscope"].filter(Boolean) as Provider[];
|
const available = [hasGoogle && "google", hasOpenai && "openai", hasDashscope && "dashscope"].filter(Boolean) as Provider[];
|
||||||
|
|
||||||
if (available.length === 1) return available[0]!;
|
if (available.length === 1) return available[0]!;
|
||||||
@@ -259,11 +354,34 @@ function detectProvider(args: CliArgs): Provider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = {
|
type ProviderModule = {
|
||||||
getDefaultModel: () => string;
|
getDefaultModel: () => string;
|
||||||
generateImage: (prompt: string, model: string, args: CliArgs) => Promise<Uint8Array>;
|
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> {
|
async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
|
||||||
if (provider === "google") {
|
if (provider === "google") {
|
||||||
return (await import("./providers/google")) as ProviderModule;
|
return (await import("./providers/google")) as ProviderModule;
|
||||||
@@ -283,9 +401,13 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await loadEnv();
|
await loadEnv();
|
||||||
|
const extendConfig = await loadExtendConfig();
|
||||||
|
const mergedArgs = mergeConfig(args, extendConfig);
|
||||||
|
|
||||||
let prompt: string | null = args.prompt;
|
if (!mergedArgs.quality) mergedArgs.quality = "2k";
|
||||||
if (!prompt && args.promptFiles.length > 0) prompt = await readPromptFromFiles(args.promptFiles);
|
|
||||||
|
let prompt: string | null = mergedArgs.prompt;
|
||||||
|
if (!prompt && mergedArgs.promptFiles.length > 0) prompt = await readPromptFromFiles(mergedArgs.promptFiles);
|
||||||
if (!prompt) prompt = await readPromptFromStdin();
|
if (!prompt) prompt = await readPromptFromStdin();
|
||||||
|
|
||||||
if (!prompt) {
|
if (!prompt) {
|
||||||
@@ -295,27 +417,39 @@ async function main(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!args.imagePath) {
|
if (!mergedArgs.imagePath) {
|
||||||
console.error("Error: --image is required");
|
console.error("Error: --image is required");
|
||||||
printUsage();
|
printUsage();
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = detectProvider(args);
|
if (mergedArgs.referenceImages.length > 0) {
|
||||||
|
await validateReferenceImages(mergedArgs.referenceImages);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = detectProvider(mergedArgs);
|
||||||
const providerModule = await loadProviderModule(provider);
|
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;
|
||||||
|
}
|
||||||
|
model = model || providerModule.getDefaultModel();
|
||||||
|
|
||||||
|
const outputPath = normalizeOutputImagePath(mergedArgs.imagePath);
|
||||||
|
|
||||||
let imageData: Uint8Array;
|
let imageData: Uint8Array;
|
||||||
let retried = false;
|
let retried = false;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
imageData = await providerModule.generateImage(prompt, model, args);
|
imageData = await providerModule.generateImage(prompt, model, mergedArgs);
|
||||||
break;
|
break;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!retried) {
|
if (!retried && isRetryableGenerationError(e)) {
|
||||||
retried = true;
|
retried = true;
|
||||||
console.error("Generation failed, retrying...");
|
console.error("Generation failed, retrying...");
|
||||||
continue;
|
continue;
|
||||||
@@ -328,7 +462,7 @@ async function main(): Promise<void> {
|
|||||||
await mkdir(dir, { recursive: true });
|
await mkdir(dir, { recursive: true });
|
||||||
await writeFile(outputPath, imageData);
|
await writeFile(outputPath, imageData);
|
||||||
|
|
||||||
if (args.json) {
|
if (mergedArgs.json) {
|
||||||
console.log(
|
console.log(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ export async function generateImage(
|
|||||||
if (!apiKey) throw new Error("DASHSCOPE_API_KEY is required");
|
if (!apiKey) throw new Error("DASHSCOPE_API_KEY is required");
|
||||||
|
|
||||||
if (args.referenceImages.length > 0) {
|
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);
|
const size = args.size ? normalizeSize(args.size) : getSizeFromAspectRatio(args.aspectRatio, args.quality);
|
||||||
|
|||||||
@@ -216,13 +216,17 @@ export async function generateImage(
|
|||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
if (isGoogleImagen(model)) {
|
if (isGoogleImagen(model)) {
|
||||||
if (args.referenceImages.length > 0) {
|
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);
|
return generateWithImagen(prompt, model, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isGoogleMultimodal(model) && args.referenceImages.length > 0) {
|
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);
|
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";
|
import type { CliArgs } from "../types";
|
||||||
|
|
||||||
export function getDefaultModel(): string {
|
export function getDefaultModel(): string {
|
||||||
return process.env.OPENAI_IMAGE_MODEL || "gpt-image-1.5";
|
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 {
|
function parseAspectRatio(ar: string): { width: number; height: number } | null {
|
||||||
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
@@ -66,20 +70,32 @@ export async function generateImage(
|
|||||||
|
|
||||||
if (!apiKey) throw new Error("OPENAI_API_KEY is required");
|
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 size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
|
||||||
|
|
||||||
const body: Record<string, any> = {
|
if (args.referenceImages.length > 0) {
|
||||||
model,
|
if (model.includes("dall-e-2") || model.includes("dall-e-3")) {
|
||||||
prompt,
|
throw new Error(
|
||||||
size,
|
"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")) {
|
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`, {
|
const res = await fetch(`${baseURL}/images/generations`, {
|
||||||
@@ -96,7 +112,62 @@ export async function generateImage(
|
|||||||
throw new Error(`OpenAI API error: ${err}`);
|
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];
|
const img = result.data[0];
|
||||||
|
|
||||||
if (img?.b64_json) {
|
if (img?.b64_json) {
|
||||||
|
|||||||
@@ -9,10 +9,23 @@ export type CliArgs = {
|
|||||||
model: string | null;
|
model: string | null;
|
||||||
aspectRatio: string | null;
|
aspectRatio: string | null;
|
||||||
size: string | null;
|
size: string | null;
|
||||||
quality: Quality;
|
quality: Quality | null;
|
||||||
imageSize: string | null;
|
imageSize: string | null;
|
||||||
referenceImages: string[];
|
referenceImages: string[];
|
||||||
n: number;
|
n: number;
|
||||||
json: boolean;
|
json: boolean;
|
||||||
help: 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: baoyu-post-to-wechat
|
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
|
# 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-browser.ts` | Image-text posts (图文) |
|
||||||
| `scripts/wechat-article.ts` | Article posting via browser (文章) |
|
| `scripts/wechat-article.ts` | Article posting via browser (文章) |
|
||||||
| `scripts/wechat-api.ts` | Article posting via API (文章) |
|
| `scripts/wechat-api.ts` | Article posting via API (文章) |
|
||||||
|
| `scripts/check-permissions.ts` | Verify environment & permissions |
|
||||||
|
|
||||||
## Preferences (EXTEND.md)
|
## 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 │
|
│ 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 (图文)
|
## Image-Text Posting (图文)
|
||||||
|
|
||||||
@@ -70,7 +121,7 @@ Publishing Progress:
|
|||||||
- [ ] Step 1: Determine input type
|
- [ ] Step 1: Determine input type
|
||||||
- [ ] Step 2: Check markdown-to-html skill
|
- [ ] Step 2: Check markdown-to-html skill
|
||||||
- [ ] Step 3: Convert to HTML
|
- [ ] 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 5: Select method and configure credentials
|
||||||
- [ ] Step 6: Publish to WeChat
|
- [ ] Step 6: Publish to WeChat
|
||||||
- [ ] Step 7: Report completion
|
- [ ] Step 7: Report completion
|
||||||
@@ -80,6 +131,13 @@ Publishing Progress:
|
|||||||
|
|
||||||
Check and load EXTEND.md settings (see Preferences section above).
|
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
|
### Step 1: Determine Input Type
|
||||||
|
|
||||||
| Input Type | Detection | Action |
|
| Input Type | Detection | Action |
|
||||||
@@ -162,11 +220,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" |
|
| 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)" |
|
| 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**:
|
**Auto-Generation Logic**:
|
||||||
- **Title**: First H1/H2 heading, or first sentence
|
- **Title**: First H1/H2 heading, or first sentence
|
||||||
- **Summary**: First paragraph, truncated to 120 characters
|
- **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
|
### Step 5: Select Publishing Method and Configure
|
||||||
|
|
||||||
**Ask publishing method** (unless specified in EXTEND.md or CLI):
|
**Ask publishing method** (unless specified in EXTEND.md or CLI):
|
||||||
@@ -213,9 +279,20 @@ WECHAT_APP_SECRET=<user_input>
|
|||||||
**API method**:
|
**API method**:
|
||||||
|
|
||||||
```bash
|
```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**:
|
**Browser method**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -237,6 +314,7 @@ Article:
|
|||||||
• Title: [title]
|
• Title: [title]
|
||||||
• Summary: [summary]
|
• Summary: [summary]
|
||||||
• Images: [N] inline images
|
• Images: [N] inline images
|
||||||
|
• Comments: [open/closed], [fans-only/all users]
|
||||||
|
|
||||||
Result:
|
Result:
|
||||||
✓ Draft saved to WeChat Official Account
|
✓ Draft saved to WeChat Official Account
|
||||||
@@ -289,6 +367,8 @@ Files created:
|
|||||||
| Multiple images | ✓ (up to 9) | ✓ (inline) | ✓ (inline) |
|
| Multiple images | ✓ (up to 9) | ✓ (inline) | ✓ (inline) |
|
||||||
| Themes | ✗ | ✓ | ✓ |
|
| Themes | ✗ | ✓ | ✓ |
|
||||||
| Auto-generate metadata | ✗ | ✓ | ✓ |
|
| Auto-generate metadata | ✗ | ✓ | ✓ |
|
||||||
|
| Default cover fallback (`imgs/cover.png`) | ✗ | ✓ | ✗ |
|
||||||
|
| Comment control (`need_open_comment`, `only_fans_can_comment`) | ✗ | ✓ | ✗ |
|
||||||
| Requires Chrome | ✓ | ✗ | ✓ |
|
| Requires Chrome | ✓ | ✗ | ✓ |
|
||||||
| Requires API credentials | ✗ | ✓ | ✗ |
|
| Requires API credentials | ✗ | ✓ | ✗ |
|
||||||
| Speed | Medium | Fast | Slow |
|
| Speed | Medium | Fast | Slow |
|
||||||
@@ -322,6 +402,8 @@ Files created:
|
|||||||
| Not logged in (browser) | First run opens browser - scan QR to log in |
|
| Not logged in (browser) | First run opens browser - scan QR to log in |
|
||||||
| Chrome not found | Set `WECHAT_BROWSER_CHROME_PATH` env var |
|
| Chrome not found | Set `WECHAT_BROWSER_CHROME_PATH` env var |
|
||||||
| Title/summary missing | Use auto-generation or provide manually |
|
| 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 |
|
| Paste fails | Check system clipboard permissions |
|
||||||
|
|
||||||
## Extension Support
|
## 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.
|
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
|
## Usage
|
||||||
|
|
||||||
```bash
|
```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> {
|
async function copyImageWindows(imagePath: string): Promise<void> {
|
||||||
|
const escaped = imagePath.replace(/'/g, "''");
|
||||||
const ps = [
|
const ps = [
|
||||||
'param([string]$Path)',
|
|
||||||
'Add-Type -AssemblyName System.Windows.Forms',
|
'Add-Type -AssemblyName System.Windows.Forms',
|
||||||
'Add-Type -AssemblyName System.Drawing',
|
'Add-Type -AssemblyName System.Drawing',
|
||||||
'$img = [System.Drawing.Image]::FromFile($Path)',
|
`$img = [System.Drawing.Image]::FromFile('${escaped}')`,
|
||||||
'[System.Windows.Forms.Clipboard]::SetImage($img)',
|
'[System.Windows.Forms.Clipboard]::SetImage($img)',
|
||||||
'$img.Dispose()',
|
'$img.Dispose()',
|
||||||
].join('; ');
|
].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> {
|
async function copyHtmlWindows(htmlFilePath: string): Promise<void> {
|
||||||
|
const escaped = htmlFilePath.replace(/'/g, "''");
|
||||||
const ps = [
|
const ps = [
|
||||||
'param([string]$Path)',
|
|
||||||
'Add-Type -AssemblyName System.Windows.Forms',
|
'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)',
|
'[System.Windows.Forms.Clipboard]::SetText($html, [System.Windows.Forms.TextDataFormat]::Html)',
|
||||||
].join('; ');
|
].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> {
|
async function copyImageToClipboard(imagePathInput: string): Promise<void> {
|
||||||
|
|||||||
@@ -120,22 +120,28 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
|
|||||||
const { frontmatter, body } = parseFrontmatter(content);
|
const { frontmatter, body } = parseFrontmatter(content);
|
||||||
|
|
||||||
let title = options?.title ?? frontmatter.title ?? '';
|
let title = options?.title ?? frontmatter.title ?? '';
|
||||||
|
let bodyWithoutTitle = body;
|
||||||
if (!title) {
|
if (!title) {
|
||||||
const lines = body.split('\n');
|
const lines = body.split('\n');
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
const headingMatch = trimmed.match(/^#{1,2}\s+(.+)$/);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
bodyWithoutTitle = body.replace(/^#{1,2}\s+.+\r?\n?/, '');
|
||||||
}
|
}
|
||||||
if (!title) title = path.basename(markdownPath, path.extname(markdownPath));
|
if (!title) title = path.basename(markdownPath, path.extname(markdownPath));
|
||||||
const author = frontmatter.author || '';
|
const author = frontmatter.author || '';
|
||||||
let summary = frontmatter.description || frontmatter.summary || '';
|
let summary = frontmatter.description || frontmatter.summary || '';
|
||||||
|
|
||||||
if (!summary) {
|
if (!summary) {
|
||||||
const lines = body.split('\n');
|
const lines = bodyWithoutTitle.split('\n');
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
@@ -161,7 +167,7 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
|
|||||||
const images: Array<{ src: string; placeholder: string }> = [];
|
const images: Array<{ src: string; placeholder: string }> = [];
|
||||||
let imageCounter = 0;
|
let imageCounter = 0;
|
||||||
|
|
||||||
const modifiedBody = body.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
|
const modifiedBody = bodyWithoutTitle.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
|
||||||
const placeholder = `WECHATIMGPH_${++imageCounter}`;
|
const placeholder = `WECHATIMGPH_${++imageCounter}`;
|
||||||
images.push({ src, placeholder });
|
images.push({ src, placeholder });
|
||||||
return placeholder;
|
return placeholder;
|
||||||
|
|||||||
@@ -374,7 +374,7 @@ export function initRenderer(opts: IOpts = {}): RendererAPI {
|
|||||||
|
|
||||||
listCounters[listCounters.length - 1] = idx + 1;
|
listCounters[listCounters.length - 1] = idx + 1;
|
||||||
|
|
||||||
const prefix = ordered ? `${idx}. ` : "• ";
|
const prefix = ordered ? "" : "• ";
|
||||||
|
|
||||||
let content: string;
|
let content: string;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ Frontmatter Fields (markdown):
|
|||||||
title Article title
|
title Article title
|
||||||
author Author name
|
author Author name
|
||||||
digest/summary Article summary
|
digest/summary Article summary
|
||||||
featureImage/coverImage/cover/image Cover image path
|
coverImage/featureImage/cover/image Cover image path
|
||||||
|
|
||||||
Comments:
|
Comments:
|
||||||
Comments are enabled by default, open to all users.
|
Comments are enabled by default, open to all users.
|
||||||
@@ -517,6 +517,13 @@ async function main(): Promise<void> {
|
|||||||
process.exit(1);
|
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}`);
|
console.error(`[wechat-api] Title: ${title}`);
|
||||||
if (author) console.error(`[wechat-api] Author: ${author}`);
|
if (author) console.error(`[wechat-api] Author: ${author}`);
|
||||||
if (digest) console.error(`[wechat-api] Digest: ${digest.slice(0, 50)}...`);
|
if (digest) console.error(`[wechat-api] Digest: ${digest.slice(0, 50)}...`);
|
||||||
@@ -547,11 +554,14 @@ async function main(): Promise<void> {
|
|||||||
htmlContent = processedHtml;
|
htmlContent = processedHtml;
|
||||||
|
|
||||||
let thumbMediaId = "";
|
let thumbMediaId = "";
|
||||||
const coverPath = args.cover ||
|
const rawCoverPath = args.cover ||
|
||||||
frontmatter.featureImage ||
|
|
||||||
frontmatter.coverImage ||
|
frontmatter.coverImage ||
|
||||||
|
frontmatter.featureImage ||
|
||||||
frontmatter.cover ||
|
frontmatter.cover ||
|
||||||
frontmatter.image;
|
frontmatter.image;
|
||||||
|
const coverPath = rawCoverPath && !path.isAbsolute(rawCoverPath) && args.cover
|
||||||
|
? path.resolve(process.cwd(), rawCoverPath)
|
||||||
|
: rawCoverPath;
|
||||||
|
|
||||||
if (coverPath) {
|
if (coverPath) {
|
||||||
console.error(`[wechat-api] Uploading cover: ${coverPath}`);
|
console.error(`[wechat-api] Uploading cover: ${coverPath}`);
|
||||||
@@ -568,7 +578,7 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (args.articleType === "news" && !thumbMediaId) {
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -397,7 +397,7 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
|
|||||||
|
|
||||||
await sleep(2000);
|
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', {
|
const menuResult = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||||
expression: `
|
expression: `
|
||||||
const menuItems = document.querySelectorAll('.new-creation__menu .new-creation__menu-item');
|
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));
|
const initialIds = new Set(initialTargets.targetInfos.map(t => t.targetId));
|
||||||
console.log(`[wechat-browser] Initial targets count: ${initialTargets.targetInfos.length}`);
|
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', {
|
const menuPos = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||||
expression: `
|
expression: `
|
||||||
(function() {
|
(function() {
|
||||||
@@ -427,10 +427,10 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
|
|||||||
const title = item.querySelector('.new-creation__menu-title');
|
const title = item.querySelector('.new-creation__menu-title');
|
||||||
const text = title?.textContent?.trim() || '';
|
const text = title?.textContent?.trim() || '';
|
||||||
console.log('Menu item text:', text);
|
console.log('Menu item text:', text);
|
||||||
if (text === '图文') {
|
if (text === '图文' || text === '贴图') {
|
||||||
item.scrollIntoView({ block: 'center' });
|
item.scrollIntoView({ block: 'center' });
|
||||||
const rect = item.getBoundingClientRect();
|
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 });
|
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}`);
|
console.log(`[wechat-browser] Menu position: ${menuPos.result.value}`);
|
||||||
|
|
||||||
const pos = menuPos.result.value !== 'null' ? JSON.parse(menuPos.result.value) : null;
|
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', {
|
await cdp.send('Input.dispatchMouseEvent', {
|
||||||
type: 'mousePressed',
|
type: 'mousePressed',
|
||||||
x: pos.x,
|
x: pos.x,
|
||||||
@@ -533,11 +533,22 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
|
|||||||
console.log(`[wechat-browser] Images: ${absolutePaths.join(', ')}`);
|
console.log(`[wechat-browser] Images: ${absolutePaths.join(', ')}`);
|
||||||
|
|
||||||
const { root } = await cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {}, { sessionId });
|
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,
|
nodeId: root.nodeId,
|
||||||
selector: '.js_upload_btn_container input[type=file]',
|
selector: '.js_upload_btn_container input[type=file]',
|
||||||
}, { sessionId });
|
}, { 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');
|
if (!nodeId) throw new Error('File input not found');
|
||||||
|
|
||||||
await cdp.send('DOM.setFileInputFiles', {
|
await cdp.send('DOM.setFileInputFiles', {
|
||||||
@@ -545,7 +556,27 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
|
|||||||
files: absolutePaths,
|
files: absolutePaths,
|
||||||
}, { sessionId });
|
}, { 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...');
|
console.log('[wechat-browser] Filling title...');
|
||||||
await cdp.send('Runtime.evaluate', {
|
await cdp.send('Runtime.evaluate', {
|
||||||
@@ -561,73 +592,134 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
|
|||||||
}, { sessionId });
|
}, { sessionId });
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
console.log('[wechat-browser] Clicking on content editor...');
|
console.log('[wechat-browser] Filling content...');
|
||||||
const editorPos = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
// Try ProseMirror editor first (new WeChat UI), then fallback to old editor
|
||||||
|
const contentResult = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||||
expression: `
|
expression: `
|
||||||
(function() {
|
(function() {
|
||||||
const editor = document.querySelector('.js_pmEditorArea');
|
const contentHtml = ${JSON.stringify('<p>' + content.split('\n').filter(l => l.trim()).join('</p><p>') + '</p>')};
|
||||||
if (editor) {
|
|
||||||
const rect = editor.getBoundingClientRect();
|
// New UI: ProseMirror contenteditable
|
||||||
return JSON.stringify({ x: rect.x + 50, y: rect.y + 20 });
|
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,
|
returnByValue: true,
|
||||||
}, { sessionId });
|
}, { sessionId });
|
||||||
|
|
||||||
if (editorPos.result.value === 'null') throw new Error('Content editor not found');
|
const contentStatus = contentResult.result.value;
|
||||||
const editorClickPos = JSON.parse(editorPos.result.value);
|
console.log(`[wechat-browser] Content result: ${contentStatus}`);
|
||||||
|
|
||||||
await cdp.send('Input.dispatchMouseEvent', {
|
if (contentStatus === 'editor_not_found') {
|
||||||
type: 'mousePressed',
|
throw new Error('Content editor not found');
|
||||||
x: editorClickPos.x,
|
}
|
||||||
y: editorClickPos.y,
|
|
||||||
button: 'left',
|
// Fallback: old editor uses keyboard simulation
|
||||||
clickCount: 1,
|
if (contentStatus.startsWith('{')) {
|
||||||
}, { sessionId });
|
const editorClickPos = JSON.parse(contentStatus);
|
||||||
await sleep(50);
|
if (editorClickPos.type === 'old') {
|
||||||
await cdp.send('Input.dispatchMouseEvent', {
|
console.log('[wechat-browser] Using old editor with keyboard simulation...');
|
||||||
type: 'mouseReleased',
|
await cdp.send('Input.dispatchMouseEvent', {
|
||||||
x: editorClickPos.x,
|
type: 'mousePressed',
|
||||||
y: editorClickPos.y,
|
x: editorClickPos.x,
|
||||||
button: 'left',
|
y: editorClickPos.y,
|
||||||
clickCount: 1,
|
button: 'left',
|
||||||
}, { sessionId });
|
clickCount: 1,
|
||||||
await sleep(300);
|
}, { sessionId });
|
||||||
|
await sleep(50);
|
||||||
console.log('[wechat-browser] Typing content with keyboard simulation...');
|
await cdp.send('Input.dispatchMouseEvent', {
|
||||||
const lines = content.split('\n');
|
type: 'mouseReleased',
|
||||||
for (let i = 0; i < lines.length; i++) {
|
x: editorClickPos.x,
|
||||||
const line = lines[i];
|
y: editorClickPos.y,
|
||||||
if (line.length > 0) {
|
button: 'left',
|
||||||
await cdp.send('Input.insertText', { text: line }, { sessionId });
|
clickCount: 1,
|
||||||
}
|
}, { sessionId });
|
||||||
if (i < lines.length - 1) {
|
await sleep(300);
|
||||||
await cdp.send('Input.dispatchKeyEvent', {
|
|
||||||
type: 'keyDown',
|
const lines = content.split('\n');
|
||||||
key: 'Enter',
|
for (let i = 0; i < lines.length; i++) {
|
||||||
code: 'Enter',
|
const line = lines[i];
|
||||||
windowsVirtualKeyCode: 13,
|
if (line!.length > 0) {
|
||||||
}, { sessionId });
|
await cdp.send('Input.insertText', { text: line }, { sessionId });
|
||||||
await cdp.send('Input.dispatchKeyEvent', {
|
}
|
||||||
type: 'keyUp',
|
if (i < lines.length - 1) {
|
||||||
key: 'Enter',
|
await cdp.send('Input.dispatchKeyEvent', {
|
||||||
code: 'Enter',
|
type: 'keyDown',
|
||||||
windowsVirtualKeyCode: 13,
|
key: 'Enter',
|
||||||
}, { sessionId });
|
code: 'Enter',
|
||||||
}
|
windowsVirtualKeyCode: 13,
|
||||||
await sleep(50);
|
}, { 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);
|
await sleep(500);
|
||||||
|
|
||||||
if (submit) {
|
if (submit) {
|
||||||
console.log('[wechat-browser] Saving as draft...');
|
console.log('[wechat-browser] Saving as draft...');
|
||||||
await cdp.send('Runtime.evaluate', {
|
const submitResult = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||||
expression: `document.querySelector('#js_submit')?.click()`,
|
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 });
|
}, { sessionId });
|
||||||
|
console.log(`[wechat-browser] Submit result: ${submitResult.result.value}`);
|
||||||
await sleep(3000);
|
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!');
|
console.log('[wechat-browser] Draft saved!');
|
||||||
} else {
|
} else {
|
||||||
console.log('[wechat-browser] Article composed (preview mode). Add --submit to save as draft.');
|
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 {
|
function printUsage(): never {
|
||||||
console.log(`Post image-text (图文) to WeChat Official Account
|
console.log(`Post image-text (贴图) to WeChat Official Account
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
npx -y bun wechat-browser.ts [options]
|
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/md-to-html.ts` | Markdown → HTML conversion |
|
||||||
| `scripts/copy-to-clipboard.ts` | Copy content to clipboard |
|
| `scripts/copy-to-clipboard.ts` | Copy content to clipboard |
|
||||||
| `scripts/paste-from-clipboard.ts` | Send real paste keystroke |
|
| `scripts/paste-from-clipboard.ts` | Send real paste keystroke |
|
||||||
|
| `scripts/check-paste-permissions.ts` | Verify environment & permissions |
|
||||||
|
|
||||||
## Preferences (EXTEND.md)
|
## Preferences (EXTEND.md)
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md" && echo "user"
|
|||||||
│ Not found │ Use defaults │
|
│ Not found │ Use defaults │
|
||||||
└───────────┴───────────────────────────────────────────────────────────────────────────┘
|
└───────────┴───────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
**EXTEND.md Supports**: Default Chrome profile | Auto-submit preference
|
**EXTEND.md Supports**: Default Chrome profile
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -63,6 +64,28 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md" && echo "user"
|
|||||||
- `bun` runtime
|
- `bun` runtime
|
||||||
- First run: log in to X manually (session saved)
|
- 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
|
## References
|
||||||
|
|
||||||
- **Regular Posts**: See `references/regular-posts.md` for manual workflow, troubleshooting, and technical details
|
- **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.
|
Text + up to 4 images.
|
||||||
|
|
||||||
```bash
|
```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
|
||||||
npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png --submit # Post
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Parameters**:
|
**Parameters**:
|
||||||
@@ -84,9 +106,10 @@ npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png --subm
|
|||||||
|-----------|-------------|
|
|-----------|-------------|
|
||||||
| `<text>` | Post content (positional) |
|
| `<text>` | Post content (positional) |
|
||||||
| `--image <path>` | Image file (repeatable, max 4) |
|
| `--image <path>` | Image file (repeatable, max 4) |
|
||||||
| `--submit` | Post (default: preview) |
|
|
||||||
| `--profile <dir>` | Custom Chrome profile |
|
| `--profile <dir>` | Custom Chrome profile |
|
||||||
|
|
||||||
|
**Note**: Script opens browser with content filled in. User reviews and publishes manually.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Video Posts
|
## Video Posts
|
||||||
@@ -94,8 +117,7 @@ npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png --subm
|
|||||||
Text + video file.
|
Text + video file.
|
||||||
|
|
||||||
```bash
|
```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 "Check this out!" --video ./clip.mp4
|
||||||
npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Amazing content" --video ./demo.mp4 --submit # Post
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Parameters**:
|
**Parameters**:
|
||||||
@@ -103,9 +125,10 @@ npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Amazing content" --video ./demo.mp4
|
|||||||
|-----------|-------------|
|
|-----------|-------------|
|
||||||
| `<text>` | Post content (positional) |
|
| `<text>` | Post content (positional) |
|
||||||
| `--video <path>` | Video file (MP4, MOV, WebM) |
|
| `--video <path>` | Video file (MP4, MOV, WebM) |
|
||||||
| `--submit` | Post (default: preview) |
|
|
||||||
| `--profile <dir>` | Custom Chrome profile |
|
| `--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.
|
**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.
|
Quote an existing tweet with comment.
|
||||||
|
|
||||||
```bash
|
```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 "Great insight!"
|
||||||
npx -y bun ${SKILL_DIR}/scripts/x-quote.ts https://x.com/user/status/123 "I agree!" --submit # Post
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Parameters**:
|
**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) |
|
| `<tweet-url>` | URL to quote (positional) |
|
||||||
| `<comment>` | Comment text (positional, optional) |
|
| `<comment>` | Comment text (positional, optional) |
|
||||||
| `--submit` | Post (default: preview) |
|
|
||||||
| `--profile <dir>` | Custom Chrome profile |
|
| `--profile <dir>` | Custom Chrome profile |
|
||||||
|
|
||||||
|
**Note**: Script opens browser with content filled in. User reviews and publishes manually.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## X Articles
|
## 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).
|
Long-form Markdown articles (requires X Premium).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md # Preview
|
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 # With cover
|
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --cover ./cover.jpg
|
||||||
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --submit # Publish
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Parameters**:
|
**Parameters**:
|
||||||
@@ -145,16 +167,29 @@ npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --submit #
|
|||||||
| `<markdown>` | Markdown file (positional) |
|
| `<markdown>` | Markdown file (positional) |
|
||||||
| `--cover <path>` | Cover image |
|
| `--cover <path>` | Cover image |
|
||||||
| `--title <text>` | Override title |
|
| `--title <text>` | Override title |
|
||||||
| `--submit` | Publish (default: preview) |
|
|
||||||
|
|
||||||
**Frontmatter**: `title`, `cover_image` supported in YAML front matter.
|
**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
|
## Notes
|
||||||
|
|
||||||
- First run: manual login required (session persists)
|
- 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
|
- Cross-platform: macOS, Linux, Windows
|
||||||
|
|
||||||
## Extension Support
|
## 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> {
|
async function copyImageWindows(imagePath: string): Promise<void> {
|
||||||
|
const escaped = imagePath.replace(/'/g, "''");
|
||||||
const ps = [
|
const ps = [
|
||||||
'param([string]$Path)',
|
|
||||||
'Add-Type -AssemblyName System.Windows.Forms',
|
'Add-Type -AssemblyName System.Windows.Forms',
|
||||||
'Add-Type -AssemblyName System.Drawing',
|
'Add-Type -AssemblyName System.Drawing',
|
||||||
'$img = [System.Drawing.Image]::FromFile($Path)',
|
`$img = [System.Drawing.Image]::FromFile('${escaped}')`,
|
||||||
'[System.Windows.Forms.Clipboard]::SetImage($img)',
|
'[System.Windows.Forms.Clipboard]::SetImage($img)',
|
||||||
'$img.Dispose()',
|
'$img.Dispose()',
|
||||||
].join('; ');
|
].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> {
|
async function copyHtmlWindows(htmlFilePath: string): Promise<void> {
|
||||||
|
const escaped = htmlFilePath.replace(/'/g, "''");
|
||||||
const ps = [
|
const ps = [
|
||||||
'param([string]$Path)',
|
|
||||||
'Add-Type -AssemblyName System.Windows.Forms',
|
'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)',
|
'[System.Windows.Forms.Clipboard]::SetText($html, [System.Windows.Forms.TextDataFormat]::Html)',
|
||||||
].join('; ');
|
].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> {
|
async function copyImageToClipboard(imagePathInput: string): Promise<void> {
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ import path from 'node:path';
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { createHash } from 'node:crypto';
|
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 {
|
interface ImageInfo {
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
localPath: string;
|
localPath: string;
|
||||||
@@ -22,25 +30,80 @@ interface ParsedMarkdown {
|
|||||||
totalBlocks: number;
|
totalBlocks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } {
|
type FrontmatterFields = Record<string, unknown>;
|
||||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
||||||
if (!match) return { frontmatter: {}, body: content };
|
|
||||||
|
|
||||||
const frontmatter: Record<string, string> = {};
|
function parseFrontmatter(content: string): { frontmatter: FrontmatterFields; body: string } {
|
||||||
const lines = match[1]!.split('\n');
|
try {
|
||||||
for (const line of lines) {
|
const parsed = frontMatter<FrontmatterFields>(content);
|
||||||
const colonIdx = line.indexOf(':');
|
return {
|
||||||
if (colonIdx > 0) {
|
frontmatter: parsed.attributes ?? {},
|
||||||
const key = line.slice(0, colonIdx).trim();
|
body: parsed.body,
|
||||||
let value = line.slice(colonIdx + 1).trim();
|
};
|
||||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
} catch {
|
||||||
value = value.slice(1, -1);
|
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> {
|
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, '>')
|
||||||
.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 } {
|
function convertMarkdownToHtml(markdown: string, imageCallback: (src: string, alt: string) => string): { html: string; totalBlocks: number } {
|
||||||
const lines = markdown.split('\n');
|
const preprocessedMarkdown = preprocessCjkMarkdown(markdown);
|
||||||
const blocks: string[] = [];
|
const blockTokens = Lexer.lex(preprocessedMarkdown, { gfm: true, breaks: true });
|
||||||
let inCodeBlock = false;
|
|
||||||
let codeBlockContent: string[] = [];
|
|
||||||
let inList = false;
|
|
||||||
let listItems: string[] = [];
|
|
||||||
let listType: 'ul' | 'ol' = 'ul';
|
|
||||||
|
|
||||||
const flushList = () => {
|
const renderer: RendererObject = {
|
||||||
if (listItems.length > 0) {
|
heading({ depth, tokens }: Tokens.Heading): string {
|
||||||
const tag = listType === 'ol' ? 'ol' : 'ul';
|
if (depth === 1) {
|
||||||
blocks.push(`<${tag}>${listItems.map((item) => `<li>${item}</li>`).join('')}</${tag}>`);
|
return '';
|
||||||
listItems = [];
|
}
|
||||||
inList = false;
|
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 => {
|
const parser = new Marked({
|
||||||
// Bold
|
gfm: true,
|
||||||
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
breaks: true,
|
||||||
text = text.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
});
|
||||||
|
parser.use({ renderer });
|
||||||
|
|
||||||
// Italic
|
const rendered = parser.parse(preprocessedMarkdown);
|
||||||
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
if (typeof rendered !== 'string') {
|
||||||
text = text.replace(/_(.+?)_/g, '<em>$1</em>');
|
throw new Error('Unexpected async markdown parse result');
|
||||||
|
|
||||||
// Links
|
|
||||||
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
|
||||||
|
|
||||||
// Inline code
|
|
||||||
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
||||||
|
|
||||||
return text;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const line = lines[i]!;
|
|
||||||
|
|
||||||
// Code block
|
|
||||||
if (line.startsWith('```')) {
|
|
||||||
if (inCodeBlock) {
|
|
||||||
// X doesn't support <pre><code>, convert to blockquote
|
|
||||||
const codeContent = codeBlockContent.map((l) => escapeHtml(l)).join('<br>');
|
|
||||||
blocks.push(`<blockquote>${codeContent}</blockquote>`);
|
|
||||||
codeBlockContent = [];
|
|
||||||
inCodeBlock = false;
|
|
||||||
} else {
|
|
||||||
flushList();
|
|
||||||
inCodeBlock = true;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inCodeBlock) {
|
|
||||||
codeBlockContent.push(line);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty line
|
|
||||||
if (line.trim() === '') {
|
|
||||||
flushList();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Image
|
|
||||||
const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/);
|
|
||||||
if (imgMatch) {
|
|
||||||
flushList();
|
|
||||||
const placeholder = imageCallback(imgMatch[2]!, imgMatch[1]!);
|
|
||||||
blocks.push(`<p>${placeholder}</p>`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Heading (H1 is title, skip it; H2-H6 become H2)
|
|
||||||
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
||||||
if (headingMatch) {
|
|
||||||
flushList();
|
|
||||||
const level = headingMatch[1]!.length;
|
|
||||||
if (level === 1) continue; // Skip H1, it's the title
|
|
||||||
blocks.push(`<h2>${processInline(headingMatch[2]!)}</h2>`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blockquote
|
|
||||||
if (line.startsWith('> ')) {
|
|
||||||
flushList();
|
|
||||||
blocks.push(`<blockquote>${processInline(line.slice(2))}</blockquote>`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unordered list
|
|
||||||
const ulMatch = line.match(/^[-*]\s+(.+)$/);
|
|
||||||
if (ulMatch) {
|
|
||||||
if (!inList || listType !== 'ul') {
|
|
||||||
flushList();
|
|
||||||
inList = true;
|
|
||||||
listType = 'ul';
|
|
||||||
}
|
|
||||||
listItems.push(processInline(ulMatch[1]!));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ordered list
|
|
||||||
const olMatch = line.match(/^\d+\.\s+(.+)$/);
|
|
||||||
if (olMatch) {
|
|
||||||
if (!inList || listType !== 'ol') {
|
|
||||||
flushList();
|
|
||||||
inList = true;
|
|
||||||
listType = 'ol';
|
|
||||||
}
|
|
||||||
listItems.push(processInline(olMatch[1]!));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Horizontal rule
|
|
||||||
if (/^[-*_]{3,}\s*$/.test(line)) {
|
|
||||||
flushList();
|
|
||||||
blocks.push('<hr>');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular paragraph
|
|
||||||
flushList();
|
|
||||||
blocks.push(`<p>${processInline(line)}</p>`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
flushList();
|
const totalBlocks = blockTokens.filter((token) => {
|
||||||
|
if (token.type === 'space') return false;
|
||||||
|
if (token.type === 'heading' && token.depth === 1) return false;
|
||||||
|
return true;
|
||||||
|
}).length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: blocks.join('\n'),
|
html: rendered,
|
||||||
totalBlocks: blocks.length,
|
totalBlocks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,59 +287,58 @@ export async function parseMarkdown(
|
|||||||
|
|
||||||
const { frontmatter, body } = parseFrontmatter(content);
|
const { frontmatter, body } = parseFrontmatter(content);
|
||||||
|
|
||||||
// Extract title from frontmatter, option, or first H1
|
let title = stripWrappingQuotes(options?.title ?? '') || pickFirstString(frontmatter, ['title']) || '';
|
||||||
let title = options?.title ?? frontmatter.title ?? '';
|
|
||||||
if (!title) {
|
if (!title) {
|
||||||
const h1Match = body.match(/^#\s+(.+)$/m);
|
title = extractTitleFromMarkdown(body);
|
||||||
if (h1Match) title = h1Match[1]!;
|
}
|
||||||
|
if (!title) {
|
||||||
|
title = path.basename(markdownPath, path.extname(markdownPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract cover image from frontmatter or option
|
let coverImagePath = stripWrappingQuotes(options?.coverImage ?? '') || pickFirstString(frontmatter, [
|
||||||
let coverImagePath = options?.coverImage ?? frontmatter.cover_image ?? frontmatter.coverImage ?? frontmatter.cover ?? frontmatter.image ?? frontmatter.featureImage ?? frontmatter.feature_image ?? null;
|
'cover_image',
|
||||||
|
'coverImage',
|
||||||
|
'cover',
|
||||||
|
'image',
|
||||||
|
'featureImage',
|
||||||
|
'feature_image',
|
||||||
|
]) || null;
|
||||||
|
if (!coverImagePath) {
|
||||||
|
coverImagePath = findCoverImageNearMarkdown(baseDir);
|
||||||
|
}
|
||||||
|
|
||||||
const images: Array<{ src: string; alt: string; blockIndex: number }> = [];
|
const images: Array<{ src: string; alt: string; blockIndex: number }> = [];
|
||||||
let imageCounter = 0;
|
let imageCounter = 0;
|
||||||
|
|
||||||
const { html, totalBlocks } = convertMarkdownToHtml(body, (src, alt) => {
|
const { html, totalBlocks } = convertMarkdownToHtml(body, (src, alt) => {
|
||||||
const placeholder = `XIMGPH_${++imageCounter}`;
|
const placeholder = `XIMGPH_${++imageCounter}`;
|
||||||
const currentBlockIndex = images.length; // Will be set properly after HTML generation
|
images.push({ src, alt, blockIndex: -1 });
|
||||||
|
|
||||||
images.push({ src, alt, blockIndex: -1 }); // blockIndex set later
|
|
||||||
return placeholder;
|
return placeholder;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update block indices by finding placeholders in HTML
|
|
||||||
const htmlLines = html.split('\n');
|
const htmlLines = html.split('\n');
|
||||||
let blockIdx = 0;
|
for (let i = 0; i < images.length; i++) {
|
||||||
for (const line of htmlLines) {
|
const placeholder = `XIMGPH_${i + 1}`;
|
||||||
for (let i = 0; i < images.length; i++) {
|
for (let lineIndex = 0; lineIndex < htmlLines.length; lineIndex++) {
|
||||||
const placeholder = `XIMGPH_${i + 1}`;
|
const regex = new RegExp(`\\b${placeholder}\\b`);
|
||||||
if (line.includes(placeholder)) {
|
if (regex.test(htmlLines[lineIndex]!)) {
|
||||||
images[i]!.blockIndex = blockIdx;
|
images[i]!.blockIndex = lineIndex;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
blockIdx++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve image paths (download remote, resolve relative)
|
|
||||||
const contentImages: ImageInfo[] = [];
|
const contentImages: ImageInfo[] = [];
|
||||||
let isFirstImage = true;
|
let firstImageAsCover: string | null = null;
|
||||||
let coverPlaceholder: string | null = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < images.length; i++) {
|
for (let i = 0; i < images.length; i++) {
|
||||||
const img = images[i]!;
|
const img = images[i]!;
|
||||||
const localPath = await resolveImagePath(img.src, baseDir, tempDir);
|
const localPath = await resolveImagePath(img.src, baseDir, tempDir);
|
||||||
|
|
||||||
// First image becomes cover if no cover specified
|
if (i === 0 && !coverImagePath) {
|
||||||
if (isFirstImage && !coverImagePath) {
|
firstImageAsCover = localPath;
|
||||||
coverImagePath = localPath;
|
|
||||||
coverPlaceholder = `XIMGPH_${i + 1}`;
|
|
||||||
isFirstImage = false;
|
|
||||||
// Don't add to contentImages, it's the cover
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isFirstImage = false;
|
|
||||||
contentImages.push({
|
contentImages.push({
|
||||||
placeholder: `XIMGPH_${i + 1}`,
|
placeholder: `XIMGPH_${i + 1}`,
|
||||||
localPath,
|
localPath,
|
||||||
@@ -327,17 +347,13 @@ export async function parseMarkdown(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove cover placeholder from HTML if first image was used as cover
|
const finalHtml = html.replace(/\n{3,}/g, '\n\n').trim();
|
||||||
let finalHtml = html;
|
|
||||||
if (coverPlaceholder) {
|
|
||||||
// Remove the placeholder and its containing <p> tag
|
|
||||||
finalHtml = finalHtml.replace(new RegExp(`<p>${coverPlaceholder}</p>\\n?`, 'g'), '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve cover image path
|
|
||||||
let resolvedCoverImage: string | null = null;
|
let resolvedCoverImage: string | null = null;
|
||||||
if (coverImagePath) {
|
if (coverImagePath) {
|
||||||
resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir);
|
resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir);
|
||||||
|
} else if (firstImageAsCover) {
|
||||||
|
resolvedCoverImage = firstImageAsCover;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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}`);
|
console.log(`[x-article] Reusing existing Chrome instance on port ${port}`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`[x-article] Launching Chrome...`);
|
console.log(`[x-article] Launching Chrome...`);
|
||||||
spawn(chromePath, [
|
const chromeArgs = [
|
||||||
`--remote-debugging-port=${port}`,
|
`--remote-debugging-port=${port}`,
|
||||||
`--user-data-dir=${profileDir}`,
|
`--user-data-dir=${profileDir}`,
|
||||||
'--no-first-run',
|
'--no-first-run',
|
||||||
@@ -117,7 +117,13 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
|||||||
'--disable-blink-features=AutomationControlled',
|
'--disable-blink-features=AutomationControlled',
|
||||||
'--start-maximized',
|
'--start-maximized',
|
||||||
X_ARTICLES_URL,
|
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;
|
let cdp: CdpConnection | null = null;
|
||||||
@@ -596,6 +602,12 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
|||||||
await sleep(500);
|
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
|
// Focus editor to ensure cursor is in position
|
||||||
await cdp.send('Runtime.evaluate', {
|
await cdp.send('Runtime.evaluate', {
|
||||||
expression: `(() => {
|
expression: `(() => {
|
||||||
@@ -613,9 +625,31 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
|||||||
console.warn(`[x-article] Failed to paste image after retries`);
|
console.warn(`[x-article] Failed to paste image after retries`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for image to upload
|
// Verify image appeared in editor
|
||||||
console.log(`[x-article] Waiting for upload...`);
|
console.log(`[x-article] Verifying image upload...`);
|
||||||
await sleep(5000);
|
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.');
|
console.log('[x-article] All images processed.');
|
||||||
@@ -732,7 +766,8 @@ async function main(): Promise<void> {
|
|||||||
if (arg === '--title' && args[i + 1]) {
|
if (arg === '--title' && args[i + 1]) {
|
||||||
title = args[++i];
|
title = args[++i];
|
||||||
} else if (arg === '--cover' && args[i + 1]) {
|
} 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') {
|
} else if (arg === '--submit') {
|
||||||
submit = true;
|
submit = true;
|
||||||
} else if (arg === '--profile' && args[i + 1]) {
|
} else if (arg === '--profile' && args[i + 1]) {
|
||||||
|
|||||||
@@ -117,6 +117,12 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
|
|||||||
continue;
|
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
|
// Wait for clipboard to be ready
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
@@ -150,8 +156,27 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
|
|||||||
}, { sessionId });
|
}, { sessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[x-browser] Waiting for image upload...');
|
console.log('[x-browser] Verifying image upload...');
|
||||||
await sleep(4000);
|
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) {
|
if (submit) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import net from 'node:net';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
export type PlatformCandidates = {
|
export type PlatformCandidates = {
|
||||||
darwin?: string[];
|
darwin?: string[];
|
||||||
@@ -216,7 +217,7 @@ export class CdpConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getScriptDir(): string {
|
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 {
|
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:
|
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)**:
|
**For each image (cover + content + ending)**:
|
||||||
1. Save prompt to `prompts/NN-{type}-[slug].md` (in user's preferred language)
|
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`
|
- **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`
|
- **Backup rule**: If image file exists, rename to `NN-{type}-[slug]-backup-YYYYMMDD-HHMMSS.png`
|
||||||
3. Report progress after each generation
|
3. Report progress after each generation
|
||||||
|
|
||||||
@@ -391,7 +402,7 @@ Reference: `references/config/watermark-guide.md`
|
|||||||
If image generation skill supports `--sessionId`:
|
If image generation skill supports `--sessionId`:
|
||||||
1. Generate unique session ID: `xhs-{topic-slug}-{timestamp}`
|
1. Generate unique session ID: `xhs-{topic-slug}-{timestamp}`
|
||||||
2. Use same session ID for all images
|
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
|
### Step 6: Completion Report
|
||||||
|
|
||||||
|
|||||||
@@ -160,7 +160,21 @@ From outline entry, format:
|
|||||||
If preferences include watermark:
|
If preferences include watermark:
|
||||||
- Add watermark section with content, position, opacity
|
- 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.
|
Assemble all sections into final prompt following base structure.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user