Compare commits

...

36 Commits

Author SHA1 Message Date
Jim Liu 宝玉 e736707628 chore: release v1.35.0 2026-02-24 22:15:29 -06:00
Jim Liu 宝玉 d863f11f61 feat(baoyu-infographic): add dense-modules layout and 3 new styles for high-density infographics
Add dense-modules layout for data-rich guides and 3 new styles:
morandi-journal, pop-laboratory, retro-pop-grid. Add keyword shortcuts
for 高密度信息大图 auto-selection.

Prompt credit: AJ (https://waytoagi.feishu.cn/wiki/YG0zwalijihRREkgmPzcWRInnUg)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 22:13:47 -06:00
Jim Liu 宝玉 2ce873c65c Merge pull request #47 from justnode/feature/add-baoyu-image-gen-provider
Feature/add baoyu image gen provider
2026-02-24 21:53:19 -06:00
Jim Liu 宝玉 964cf1e045 chore: release v1.34.2 2026-02-24 18:51:36 -06:00
Jim Liu 宝玉 eded9a98bb docs(baoyu-post-to-wechat): enforce explicit theme passing for markdown conversion 2026-02-24 18:51:10 -06:00
Jim Liu 宝玉 a64fdbd23f docs(baoyu-markdown-to-html): clarify theme resolution with EXTEND.md fallbacks 2026-02-24 18:51:04 -06:00
justnodejs 36b9c5e197 docs(baoyu-image-gen): add replicate model configuration documentation 2026-02-24 23:25:38 +08:00
justnodejs 851497abbd refactor(baoyu-image-gen): update replicate default model to nano-banana-pro 2026-02-24 20:26:47 +08:00
justnodejs 65a561e654 feat(baoyu-image-gen): add replicate provider 2026-02-24 19:12:36 +08:00
Jim Liu 宝玉 7b2c02a007 chore: release v1.34.1 2026-02-20 03:00:26 -06:00
Jim Liu 宝玉 98f49eae96 Merge pull request #45 from LyInfi/fix/wechat-browser-upload-progress-reeval
fix(wechat-browser): fix upload progress check crashing on second iteration
2026-02-19 18:41:51 -06:00
LyInfi 1bdf44df9e fix(wechat-browser): fix upload progress check crashing on second iteration
Runtime.evaluate reuses the same JS execution context across calls in a
session. The previous expression used `const thumbs = ...` which throws
"Identifier 'thumbs' has already been declared" on the second loop
iteration, causing result.value to be undefined and JSON.parse to throw
"JSON Parse error: Unexpected identifier 'undefined'".

Fix by inlining the querySelector into a single expression with no
variable declaration, eliminating the re-declaration error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 12:12:26 +08:00
Jim Liu 宝玉 d59ecf22c1 chore: release v1.34.0 2026-02-17 13:32:50 -06:00
Jim Liu 宝玉 22fa1a62fc refactor(baoyu-article-illustrator): enforce prompt file creation before image generation 2026-02-17 13:30:34 -06:00
Jim Liu 宝玉 3d48eed33a Merge pull request #44 from jeffrey94/feat/ref-image-chain
feat(baoyu-xhs-images): add reference image chain for visual consistency
2026-02-17 13:19:34 -06:00
Jim Liu 宝玉 8f1c4a65dd chore: release v1.33.1 2026-02-14 14:52:51 -06:00
Jim Liu 宝玉 9b97720f16 docs(baoyu-post-to-x): remove --submit flag and clarify manual publish workflow 2026-02-14 14:48:18 -06:00
Jim Liu 宝玉 145e1d2d04 refactor(baoyu-post-to-x): replace hand-rolled markdown parser with marked ecosystem
Switch md-to-html from manual line-by-line parsing to marked + front-matter +
highlight.js + remark-cjk-friendly for robust markdown conversion with syntax
highlighting, proper CJK handling, and standard frontmatter parsing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 14:37:14 -06:00
jeffrey94 49403b6fab feat(baoyu-xhs-images): add reference image chain for visual consistency
When generating multi-image series, use image 1 as --ref for all
subsequent images. This anchors character design, color rendering,
and illustration style across the entire series — critical for styles
with recurring characters or mascots.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 00:49:09 +08:00
Jim Liu 宝玉 6d56f1dae8 chore: release v1.33.0 2026-02-13 20:28:11 -06:00
Jim Liu 宝玉 dd08a2aa89 feat(baoyu-post-to-wechat): add pre-flight environment check script 2026-02-13 20:27:31 -06:00
Jim Liu 宝玉 d9b47debb3 feat(baoyu-post-to-x): add pre-flight check and image upload verification 2026-02-13 20:27:27 -06:00
Jim Liu 宝玉 6cc8627ca8 chore: release v1.32.0 2026-02-12 01:58:23 -06:00
Jim Liu 宝玉 54103dfd7d refactor(baoyu-post-to-wechat): prioritize coverImage over featureImage in frontmatter lookup 2026-02-12 01:56:32 -06:00
Jim Liu 宝玉 6012b9cc88 refactor(baoyu-format-markdown): rename featureImage to coverImage as primary frontmatter key 2026-02-12 01:56:28 -06:00
Jim Liu 宝玉 260b71cdd7 feat(baoyu-danger-x-to-markdown): add --download-media flag and media localization 2026-02-12 01:56:24 -06:00
Jim Liu 宝玉 9ff468a6a7 chore: release v1.31.2 2026-02-10 16:05:48 -06:00
Jim Liu 宝玉 c83d114a73 fix(baoyu-post-to-x): fix Windows clipboard and path handling
- Embed file path directly in PowerShell script with single-quote escaping
  instead of using param()/−Path which fails with -Command parameter.
- Use fileURLToPath() for getScriptDir() to handle Windows drive-letter paths.
2026-02-10 16:05:09 -06:00
Jim Liu 宝玉 e964100dd9 fix(baoyu-post-to-wechat): fix PowerShell clipboard copy on Windows
Embed file path directly in PowerShell script with single-quote escaping
instead of using param()/−Path which fails with -Command parameter.
2026-02-10 16:05:03 -06:00
Jim Liu 宝玉 6c0ae7a86a chore: release v1.31.1 2026-02-10 15:50:31 -06:00
Jim Liu 宝玉 52ade637af fix(baoyu-post-to-x): fix Chrome launch on macOS and cover image path resolution
Use open -na on macOS to avoid process blocking; resolve relative cover image
paths to absolute paths.
2026-02-10 15:46:14 -06:00
Jim Liu 宝玉 569beebdd6 feat(baoyu-post-to-wechat): adapt to new WeChat UI and fix digest/cover handling
Rename 图文 to 贴图 throughout; add ProseMirror editor support with old editor
fallback; add fallback file input selector; add upload progress monitoring;
improve save button detection with toast verification; truncate digest > 120
chars at punctuation boundary; fix cover image relative path resolution.
2026-02-10 15:46:09 -06:00
Jim Liu 宝玉 6cbf0f4e52 chore: release v1.31.0 2026-02-07 15:51:09 -06:00
Jim Liu 宝玉 5615e6150f feat(baoyu-post-to-wechat): add comment control, cover fallback chain, and first-time setup 2026-02-07 15:50:23 -06:00
Jim Liu 宝玉 7465f37dcf chore: release v1.30.3 2026-02-06 16:22:02 -06:00
Jim Liu 宝玉 38a1b90db3 refactor(baoyu-article-illustrator): optimize SKILL.md from 197 to 150 lines 2026-02-06 16:21:58 -06:00
44 changed files with 2744 additions and 459 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
}, },
"metadata": { "metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency", "description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.30.2" "version": "1.35.0"
}, },
"plugins": [ "plugins": [
{ {
+77
View File
@@ -2,6 +2,83 @@
English | [中文](./CHANGELOG.zh.md) English | [中文](./CHANGELOG.zh.md)
## 1.35.0 - 2026-02-24
### Features
- `baoyu-image-gen`: add Replicate provider support with configurable models (by @justnode)
- `baoyu-infographic`: add `dense-modules` layout and 3 new styles (`morandi-journal`, `pop-laboratory`, `retro-pop-grid`) for high-density infographics. Add keyword shortcuts for auto-selection. Prompt credit: [AJ](https://waytoagi.feishu.cn/wiki/YG0zwalijihRREkgmPzcWRInnUg)
### Documentation
- `baoyu-image-gen`: add Replicate model configuration documentation
## 1.34.2 - 2026-02-25
### Documentation
- `baoyu-markdown-to-html`: clarify theme resolution order with local and cross-skill EXTEND.md fallbacks before prompting user.
- `baoyu-post-to-wechat`: align markdown conversion theme handling with deterministic fallback (`CLI --theme` -> EXTEND.md `default_theme` -> `default`) and require explicit `--theme` parameter.
## 1.34.1 - 2026-02-20
### Fixes
- `baoyu-post-to-wechat`: fix upload progress check crashing on second iteration (by @LyInfi)
## 1.34.0 - 2026-02-17
### Features
- `baoyu-xhs-images`: add reference image chain for visual consistency across multi-image series (by @jeffrey94)
### Refactor
- `baoyu-article-illustrator`: enforce prompt file creation as blocking step before image generation, add structured prompt quality requirements (ZONES / LABELS / COLORS / STYLE / ASPECT) and verification checklist.
## 1.33.1 - 2026-02-14
### Refactor
- `baoyu-post-to-x`: replace hand-rolled markdown parser with marked ecosystem for X Articles HTML conversion.
### Documentation
- `baoyu-post-to-x`: remove `--submit` flag from all scripts; clarify that scripts only fill content into browser for manual review and publish.
## 1.33.0 - 2026-02-13
### Features
- `baoyu-post-to-x`: add pre-flight environment check script (`check-paste-permissions.ts`); add troubleshooting section for Chrome debug port conflicts; replace fixed sleep with image upload verification polling up to 15s.
- `baoyu-post-to-wechat`: add pre-flight environment check script (`check-permissions.ts`) covering Chrome, profile isolation, Bun, Accessibility, clipboard, paste keystroke, API credentials.
## 1.32.0 - 2026-02-12
### Features
- `baoyu-danger-x-to-markdown`: add `--download-media` flag to download images/videos locally and rewrite markdown links to relative paths; add media localization module; add first-time setup with EXTEND.md preferences; add `coverImage` to frontmatter output.
### Refactor
- `baoyu-danger-x-to-markdown`: use camelCase for frontmatter keys (`tweetCount`, `coverImage`, `requestedUrl`, etc.).
- `baoyu-format-markdown`: rename `featureImage` to `coverImage` as primary frontmatter key (with `featureImage` as accepted alias).
- `baoyu-post-to-wechat`: prioritize `coverImage` over `featureImage` in cover image frontmatter lookup order.
## 1.31.2 - 2026-02-10
### Fixes
- `baoyu-post-to-wechat`: fix PowerShell clipboard copy failing on Windows due to `param()`/`-Path` not working with `-Command`.
- `baoyu-post-to-x`: fix PowerShell clipboard copy on Windows (same issue); fix `getScriptDir()` returning invalid path on Windows (`/C:/...` prefix).
## 1.31.1 - 2026-02-10
### Features
- `baoyu-post-to-wechat`: adapt to new WeChat UI — rename 图文 to 贴图; add ProseMirror editor support with old editor fallback; add fallback file input selector; add upload progress monitoring; improve save button detection with toast verification.
### Fixes
- `baoyu-post-to-wechat`: truncate digest > 120 chars at punctuation boundary; fix cover image relative path resolution.
- `baoyu-post-to-x`: fix Chrome launch on macOS via `open -na`; fix cover image relative path resolution.
## 1.31.0 - 2026-02-07
### Features
- `baoyu-post-to-wechat`: add comment control settings (`need_open_comment`, `only_fans_can_comment`); add cover image fallback chain (CLI → frontmatter → `imgs/cover.png` → first inline image); add author resolution priority; add first-time setup flow with EXTEND.md preferences.
## 1.30.3 - 2026-02-06
### Refactor
- `baoyu-article-illustrator`: optimize SKILL.md from 197 to 150 lines (24% reduction); apply progressive disclosure pattern with concise overview and detailed references.
## 1.30.2 - 2026-02-06 ## 1.30.2 - 2026-02-06
### Refactor ### Refactor
+77
View File
@@ -2,6 +2,83 @@
[English](./CHANGELOG.md) | 中文 [English](./CHANGELOG.md) | 中文
## 1.35.0 - 2026-02-24
### 新功能
- `baoyu-image-gen`:新增 Replicate 图片生成服务,支持自定义模型配置 (by @justnode)
- `baoyu-infographic`:新增 `dense-modules` 高密度模块布局及 3 种新风格(`morandi-journal``pop-laboratory``retro-pop-grid`),支持关键词快捷选择。高密度信息大图提示词来自 [AJ](https://waytoagi.feishu.cn/wiki/YG0zwalijihRREkgmPzcWRInnUg)
### 文档
- `baoyu-image-gen`:补充 Replicate 模型配置说明文档
## 1.34.2 - 2026-02-25
### 文档
- `baoyu-markdown-to-html`:明确主题解析优先级,先读取本技能与跨技能 EXTEND.md 的 `default_theme`,仅在未命中时询问用户。
- `baoyu-post-to-wechat`:统一 markdown 转 HTML 的主题解析回退链(CLI `--theme` -> EXTEND.md `default_theme` -> `default`),并强制始终显式传入 `--theme` 参数。
## 1.34.1 - 2026-02-20
### 修复
- `baoyu-post-to-wechat`:修复上传进度检查在第二次迭代时崩溃的问题 (by @LyInfi)
## 1.34.0 - 2026-02-17
### 新功能
- `baoyu-xhs-images`:新增参考图片链功能,确保多图系列的视觉一致性 (by @jeffrey94)
### 重构
- `baoyu-article-illustrator`:将提示词文件创建设为生成图片前的阻断步骤,新增结构化提示词质量要求(ZONES / LABELS / COLORS / STYLE / ASPECT)和验证清单。
## 1.33.1 - 2026-02-14
### 重构
- `baoyu-post-to-x`:将手写 markdown 解析器替换为 marked 生态系统,用于 X Articles HTML 转换。
### 文档
- `baoyu-post-to-x`:移除所有脚本的 `--submit` 参数;明确脚本仅将内容填充到浏览器,由用户手动审核和发布。
## 1.33.0 - 2026-02-13
### 新功能
- `baoyu-post-to-x`:新增环境预检脚本(`check-paste-permissions.ts`);新增 Chrome 调试端口冲突的故障排查说明;将固定等待替换为图片上传轮询验证(最长 15 秒)。
- `baoyu-post-to-wechat`:新增环境预检脚本(`check-permissions.ts`),检查 Chrome、配置文件隔离、Bun、辅助功能、剪贴板、粘贴按键和 API 凭据。
## 1.32.0 - 2026-02-12
### 新功能
- `baoyu-danger-x-to-markdown`:新增 `--download-media` 参数,支持将图片/视频下载到本地并将 markdown 链接改写为相对路径;新增媒体本地化模块;新增首次使用 EXTEND.md 偏好设置;在 frontmatter 中输出 `coverImage`
### 重构
- `baoyu-danger-x-to-markdown`frontmatter 字段改为 camelCase`tweetCount``coverImage``requestedUrl` 等)。
- `baoyu-format-markdown`:将主 frontmatter 字段从 `featureImage` 更名为 `coverImage`(兼容 `featureImage`)。
- `baoyu-post-to-wechat`:封面图片 frontmatter 查找顺序中优先使用 `coverImage`
## 1.31.2 - 2026-02-10
### 修复
- `baoyu-post-to-wechat`:修复 Windows 上 PowerShell 剪贴板复制失败的问题(`param()`/`-Path``-Command` 参数不兼容)。
- `baoyu-post-to-x`:修复 Windows 上 PowerShell 剪贴板复制(同上);修复 `getScriptDir()` 在 Windows 上返回无效路径(`/C:/...` 前缀)。
## 1.31.1 - 2026-02-10
### 新功能
- `baoyu-post-to-wechat`:适配微信新版 UI — 图文更名为贴图;新增 ProseMirror 编辑器支持(兼容旧版编辑器);新增备用文件上传选择器;新增上传进度监控;改进保存按钮检测并增加 toast 验证。
### 修复
- `baoyu-post-to-wechat`:摘要超过 120 字符时在标点处截断;修复封面图片相对路径解析。
- `baoyu-post-to-x`:修复 macOS 上 Chrome 启动问题(使用 `open -na`);修复封面图片相对路径解析。
## 1.31.0 - 2026-02-07
### 新功能
- `baoyu-post-to-wechat`:新增评论控制设置(`need_open_comment``only_fans_can_comment`);新增封面图片回退链(CLI → frontmatter → `imgs/cover.png` → 首张内联图片);新增作者优先级解析;新增首次使用引导流程和 EXTEND.md 偏好配置。
## 1.30.3 - 2026-02-06
### 重构
- `baoyu-article-illustrator`:优化 SKILL.md 从 197 行精简至 150 行(减少 24%);采用渐进式披露模式,主文件提供简洁概览,详细内容通过引用文件提供。
## 1.30.2 - 2026-02-06 ## 1.30.2 - 2026-02-06
### 重构 ### 重构
+9 -6
View File
@@ -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
View File
@@ -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 frontmattertitle、slug、summary、featureImage 2. 检查/创建 YAML frontmattertitle、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` |
**格式化规则** **格式化规则**
| 元素 | 格式 | | 元素 | 格式 |
+56 -98
View File
@@ -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 `![description](path/NN-{type}-{slug}.png)` after corresponding paragraphs. Insert `![description](path/NN-{type}-{slug}.png)` 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
+68 -4
View File
@@ -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(`![](${coverUrl})`);
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) {
+1 -1
View File
@@ -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
+38 -5
View File
@@ -1,11 +1,11 @@
--- ---
name: baoyu-image-gen name: baoyu-image-gen
description: AI image generation with OpenAI, Google and DashScope APIs. Supports text-to-image, reference images, aspect ratios. Sequential by default; parallel generation available on request. Use when user asks to generate, create, or draw images. description: AI image generation with OpenAI, Google, DashScope and Replicate APIs. Supports text-to-image, reference images, aspect ratios. Sequential by default; parallel generation available on request. Use when user asks to generate, create, or draw images.
--- ---
# Image Generation (AI SDK) # Image Generation (AI SDK)
Official API-based image generation. Supports OpenAI, Google and DashScope (阿里通义万象) providers. Official API-based image generation. Supports OpenAI, Google, DashScope (阿里通义万象) and Replicate providers.
## Script Directory ## Script Directory
@@ -71,6 +71,12 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provi
# DashScope (阿里通义万象) # DashScope (阿里通义万象)
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "一只可爱的猫" --image out.png --provider dashscope npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "一只可爱的猫" --image out.png --provider dashscope
# Replicate (google/nano-banana-pro)
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
# Replicate with specific model
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate --model google/nano-banana
``` ```
## Options ## Options
@@ -80,7 +86,7 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "一只可爱的猫" --image ou
| `--prompt <text>`, `-p` | Prompt text | | `--prompt <text>`, `-p` | Prompt text |
| `--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\|replicate` | Force provider (default: google) |
| `--model <id>`, `-m` | Model ID (`--ref` with OpenAI requires GPT Image model, e.g. `gpt-image-1.5`) | | `--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`) |
@@ -97,19 +103,46 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "一只可爱的猫" --image ou
| `OPENAI_API_KEY` | OpenAI API key | | `OPENAI_API_KEY` | OpenAI API key |
| `GOOGLE_API_KEY` | Google API key | | `GOOGLE_API_KEY` | Google API key |
| `DASHSCOPE_API_KEY` | DashScope API key (阿里云) | | `DASHSCOPE_API_KEY` | DashScope API key (阿里云) |
| `REPLICATE_API_TOKEN` | Replicate API token |
| `OPENAI_IMAGE_MODEL` | OpenAI model override | | `OPENAI_IMAGE_MODEL` | OpenAI model override |
| `GOOGLE_IMAGE_MODEL` | Google model override | | `GOOGLE_IMAGE_MODEL` | Google model override |
| `DASHSCOPE_IMAGE_MODEL` | DashScope model override (default: z-image-turbo) | | `DASHSCOPE_IMAGE_MODEL` | DashScope model override (default: z-image-turbo) |
| `REPLICATE_IMAGE_MODEL` | Replicate model override (default: google/nano-banana-pro) |
| `OPENAI_BASE_URL` | Custom OpenAI endpoint | | `OPENAI_BASE_URL` | Custom OpenAI endpoint |
| `GOOGLE_BASE_URL` | Custom Google endpoint | | `GOOGLE_BASE_URL` | Custom Google endpoint |
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint | | `DASHSCOPE_BASE_URL` | Custom DashScope endpoint |
| `REPLICATE_BASE_URL` | Custom Replicate endpoint |
**Load Priority**: CLI args > EXTEND.md > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env` **Load Priority**: CLI args > EXTEND.md > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
## Replicate Model Configuration
When using `--provider replicate`, the model can be configured in the following ways (highest priority first):
1. CLI flag: `--model <owner/name>`
2. EXTEND.md: `default_model.replicate`
3. Env var: `REPLICATE_IMAGE_MODEL`
4. Built-in default: `google/nano-banana-pro`
Supported model formats:
- `owner/name` (recommended for official models), e.g. `google/nano-banana-pro`
- `owner/name:version` (community models by version), e.g. `stability-ai/sdxl:<version>`
Examples:
```bash
# Use Replicate default model
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
# Override model explicitly
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate --model google/nano-banana
```
## Provider Selection ## Provider Selection
1. `--ref` provided + no `--provider` → auto-select Google first, then OpenAI 1. `--ref` provided + no `--provider` → auto-select Google first, then OpenAI, then Replicate
2. `--provider` specified → use it (if `--ref`, must be `google` or `openai`) 2. `--provider` specified → use it (if `--ref`, must be `google`, `openai`, or `replicate`)
3. Only one API key available → use that provider 3. Only one API key available → use that provider
4. Multiple available → default to Google 4. Multiple available → default to Google
@@ -11,7 +11,7 @@ description: EXTEND.md YAML schema for baoyu-image-gen user preferences
--- ---
version: 1 version: 1
default_provider: null # google|openai|dashscope|null (null = auto-detect) default_provider: null # google|openai|dashscope|replicate|null (null = auto-detect)
default_quality: null # normal|2k|null (null = use default: 2k) default_quality: null # normal|2k|null (null = use default: 2k)
@@ -23,6 +23,7 @@ default_model:
google: null # e.g., "gemini-3-pro-image-preview" google: null # e.g., "gemini-3-pro-image-preview"
openai: null # e.g., "gpt-image-1.5" openai: null # e.g., "gpt-image-1.5"
dashscope: null # e.g., "z-image-turbo" dashscope: null # e.g., "z-image-turbo"
replicate: null # e.g., "google/nano-banana-pro"
--- ---
``` ```
@@ -38,6 +39,7 @@ default_model:
| `default_model.google` | string\|null | null | Google default model | | `default_model.google` | string\|null | null | Google default model |
| `default_model.openai` | string\|null | null | OpenAI default model | | `default_model.openai` | string\|null | null | OpenAI default model |
| `default_model.dashscope` | string\|null | null | DashScope default model | | `default_model.dashscope` | string\|null | null | DashScope default model |
| `default_model.replicate` | string\|null | null | Replicate default model |
## Examples ## Examples
@@ -62,5 +64,6 @@ default_model:
google: "gemini-3-pro-image-preview" google: "gemini-3-pro-image-preview"
openai: "gpt-image-1.5" openai: "gpt-image-1.5"
dashscope: "z-image-turbo" dashscope: "z-image-turbo"
replicate: "google/nano-banana-pro"
--- ---
``` ```
+18 -9
View File
@@ -14,7 +14,7 @@ Options:
-p, --prompt <text> Prompt text -p, --prompt <text> Prompt text
--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 (auto-detect by default) --provider google|openai|dashscope|replicate Force provider (auto-detect by default)
-m, --model <id> Model ID -m, --model <id> Model ID
--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)
@@ -30,12 +30,15 @@ Environment variables:
GOOGLE_API_KEY Google API key GOOGLE_API_KEY Google API key
GEMINI_API_KEY Gemini API key (alias for GOOGLE_API_KEY) GEMINI_API_KEY Gemini API key (alias for GOOGLE_API_KEY)
DASHSCOPE_API_KEY DashScope API key (阿里云通义万象) DASHSCOPE_API_KEY DashScope API key (阿里云通义万象)
REPLICATE_API_TOKEN Replicate API token
OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-1.5) OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-1.5)
GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image-preview) GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image-preview)
DASHSCOPE_IMAGE_MODEL Default DashScope model (z-image-turbo) DASHSCOPE_IMAGE_MODEL Default DashScope model (z-image-turbo)
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-pro)
OPENAI_BASE_URL Custom OpenAI endpoint OPENAI_BASE_URL Custom OpenAI endpoint
GOOGLE_BASE_URL Custom Google endpoint GOOGLE_BASE_URL Custom Google endpoint
DASHSCOPE_BASE_URL Custom DashScope endpoint DASHSCOPE_BASE_URL Custom DashScope endpoint
REPLICATE_BASE_URL Custom Replicate endpoint
Env file load order: CLI args > EXTEND.md > 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`);
} }
@@ -108,7 +111,7 @@ function parseArgs(argv: string[]): CliArgs {
if (a === "--provider") { if (a === "--provider") {
const v = argv[++i]; const v = argv[++i];
if (v !== "google" && v !== "openai" && v !== "dashscope") throw new Error(`Invalid provider: ${v}`); if (v !== "google" && v !== "openai" && v !== "dashscope" && v !== "replicate") throw new Error(`Invalid provider: ${v}`);
out.provider = v; out.provider = v;
continue; continue;
} }
@@ -250,9 +253,9 @@ function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
} else if (key === "default_image_size") { } else if (key === "default_image_size") {
config.default_image_size = value === "null" ? null : (value as "1K" | "2K" | "4K"); config.default_image_size = value === "null" ? null : (value as "1K" | "2K" | "4K");
} else if (key === "default_model") { } else if (key === "default_model") {
config.default_model = { google: null, openai: null, dashscope: null }; config.default_model = { google: null, openai: null, dashscope: null, replicate: null };
currentKey = "default_model"; currentKey = "default_model";
} else if (currentKey === "default_model" && (key === "google" || key === "openai" || key === "dashscope")) { } else if (currentKey === "default_model" && (key === "google" || key === "openai" || key === "dashscope" || key === "replicate")) {
const cleaned = value.replace(/['"]/g, ""); const cleaned = value.replace(/['"]/g, "");
config.default_model![key] = cleaned === "null" ? null : cleaned; config.default_model![key] = cleaned === "null" ? null : cleaned;
} }
@@ -323,9 +326,9 @@ 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") { if (args.referenceImages.length > 0 && args.provider && args.provider !== "google" && args.provider !== "openai" && args.provider !== "replicate") {
throw new Error( throw new Error(
"Reference images require a ref-capable provider. Use --provider google (Gemini multimodal) or --provider openai (GPT Image edits)." "Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), or --provider replicate."
); );
} }
@@ -334,22 +337,24 @@ function detectProvider(args: CliArgs): 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;
const hasReplicate = !!process.env.REPLICATE_API_TOKEN;
if (args.referenceImages.length > 0) { if (args.referenceImages.length > 0) {
if (hasGoogle) return "google"; if (hasGoogle) return "google";
if (hasOpenai) return "openai"; if (hasOpenai) return "openai";
if (hasReplicate) return "replicate";
throw new Error( throw new Error(
"Reference images require Google or OpenAI. Set GOOGLE_API_KEY/GEMINI_API_KEY or OPENAI_API_KEY, or remove --ref." "Reference images require Google, OpenAI or Replicate. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, or REPLICATE_API_TOKEN, or remove --ref."
); );
} }
const available = [hasGoogle && "google", hasOpenai && "openai", hasDashscope && "dashscope"].filter(Boolean) as Provider[]; const available = [hasGoogle && "google", hasOpenai && "openai", hasDashscope && "dashscope", hasReplicate && "replicate"].filter(Boolean) as Provider[];
if (available.length === 1) return available[0]!; if (available.length === 1) return available[0]!;
if (available.length > 1) return available[0]!; if (available.length > 1) return available[0]!;
throw new Error( throw new Error(
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, or DASHSCOPE_API_KEY.\n" + "No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, DASHSCOPE_API_KEY, or REPLICATE_API_TOKEN.\n" +
"Create ~/.baoyu-skills/.env or <cwd>/.baoyu-skills/.env with your keys." "Create ~/.baoyu-skills/.env or <cwd>/.baoyu-skills/.env with your keys."
); );
} }
@@ -389,6 +394,9 @@ async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
if (provider === "dashscope") { if (provider === "dashscope") {
return (await import("./providers/dashscope")) as ProviderModule; return (await import("./providers/dashscope")) as ProviderModule;
} }
if (provider === "replicate") {
return (await import("./providers/replicate")) as ProviderModule;
}
return (await import("./providers/openai")) as ProviderModule; return (await import("./providers/openai")) as ProviderModule;
} }
@@ -436,6 +444,7 @@ async function main(): Promise<void> {
if (provider === "google") model = extendConfig.default_model.google ?? null; if (provider === "google") model = extendConfig.default_model.google ?? null;
if (provider === "openai") model = extendConfig.default_model.openai ?? null; if (provider === "openai") model = extendConfig.default_model.openai ?? null;
if (provider === "dashscope") model = extendConfig.default_model.dashscope ?? null; if (provider === "dashscope") model = extendConfig.default_model.dashscope ?? null;
if (provider === "replicate") model = extendConfig.default_model.replicate ?? null;
} }
model = model || providerModule.getDefaultModel(); model = model || providerModule.getDefaultModel();
@@ -0,0 +1,203 @@
import path from "node:path";
import { readFile } from "node:fs/promises";
import type { CliArgs } from "../types";
const DEFAULT_MODEL = "google/nano-banana-pro";
const SYNC_WAIT_SECONDS = 60;
const POLL_INTERVAL_MS = 2000;
const MAX_POLL_MS = 300_000;
export function getDefaultModel(): string {
return process.env.REPLICATE_IMAGE_MODEL || DEFAULT_MODEL;
}
function getApiToken(): string | null {
return process.env.REPLICATE_API_TOKEN || null;
}
function getBaseUrl(): string {
const base = process.env.REPLICATE_BASE_URL || "https://api.replicate.com";
return base.replace(/\/+$/g, "");
}
function parseModelId(model: string): { owner: string; name: string; version: string | null } {
const [ownerName, version] = model.split(":");
const parts = ownerName!.split("/");
if (parts.length !== 2 || !parts[0] || !parts[1]) {
throw new Error(
`Invalid Replicate model format: "${model}". Expected "owner/name" or "owner/name:version".`
);
}
return { owner: parts[0], name: parts[1], version: version || null };
}
function buildInput(prompt: string, args: CliArgs, referenceImages: string[]): Record<string, unknown> {
const input: Record<string, unknown> = { prompt };
if (args.aspectRatio) {
input.aspect_ratio = args.aspectRatio;
}
if (args.n > 1) {
input.number_of_images = args.n;
}
input.output_format = "png";
if (referenceImages.length > 0) {
if (referenceImages.length === 1) {
input.image = referenceImages[0];
} else {
for (let i = 0; i < referenceImages.length; i++) {
input[`image${i > 0 ? i + 1 : ""}`] = referenceImages[i];
}
}
}
return input;
}
async function readImageAsDataUrl(p: string): Promise<string> {
const buf = await readFile(p);
const ext = path.extname(p).toLowerCase();
let mimeType = "image/png";
if (ext === ".jpg" || ext === ".jpeg") mimeType = "image/jpeg";
else if (ext === ".gif") mimeType = "image/gif";
else if (ext === ".webp") mimeType = "image/webp";
return `data:${mimeType};base64,${buf.toString("base64")}`;
}
type PredictionResponse = {
id: string;
status: string;
output: unknown;
error: string | null;
urls?: { get?: string };
};
async function createPrediction(
apiToken: string,
model: { owner: string; name: string; version: string | null },
input: Record<string, unknown>,
sync: boolean
): Promise<PredictionResponse> {
const baseUrl = getBaseUrl();
let url: string;
const body: Record<string, unknown> = { input };
if (model.version) {
url = `${baseUrl}/v1/predictions`;
body.version = model.version;
} else {
url = `${baseUrl}/v1/models/${model.owner}/${model.name}/predictions`;
}
const headers: Record<string, string> = {
Authorization: `Bearer ${apiToken}`,
"Content-Type": "application/json",
};
if (sync) {
headers["Prefer"] = `wait=${SYNC_WAIT_SECONDS}`;
}
const res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Replicate API error (${res.status}): ${err}`);
}
return (await res.json()) as PredictionResponse;
}
async function pollPrediction(apiToken: string, getUrl: string): Promise<PredictionResponse> {
const start = Date.now();
while (Date.now() - start < MAX_POLL_MS) {
const res = await fetch(getUrl, {
headers: { Authorization: `Bearer ${apiToken}` },
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Replicate poll error (${res.status}): ${err}`);
}
const prediction = (await res.json()) as PredictionResponse;
if (prediction.status === "succeeded") return prediction;
if (prediction.status === "failed" || prediction.status === "canceled") {
throw new Error(`Replicate prediction ${prediction.status}: ${prediction.error || "unknown error"}`);
}
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
}
throw new Error(`Replicate prediction timed out after ${MAX_POLL_MS / 1000}s`);
}
function extractOutputUrl(prediction: PredictionResponse): string {
const output = prediction.output;
if (typeof output === "string") return output;
if (Array.isArray(output)) {
const first = output[0];
if (typeof first === "string") return first;
}
if (output && typeof output === "object" && "url" in output) {
const url = (output as Record<string, unknown>).url;
if (typeof url === "string") return url;
}
throw new Error(`Unexpected Replicate output format: ${JSON.stringify(output)}`);
}
async function downloadImage(url: string): Promise<Uint8Array> {
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to download image from Replicate: ${res.status}`);
const buf = await res.arrayBuffer();
return new Uint8Array(buf);
}
export async function generateImage(
prompt: string,
model: string,
args: CliArgs
): Promise<Uint8Array> {
const apiToken = getApiToken();
if (!apiToken) throw new Error("REPLICATE_API_TOKEN is required. Get one at https://replicate.com/account/api-tokens");
const parsedModel = parseModelId(model);
const refDataUrls: string[] = [];
for (const refPath of args.referenceImages) {
refDataUrls.push(await readImageAsDataUrl(refPath));
}
const input = buildInput(prompt, args, refDataUrls);
console.log(`Generating image with Replicate (${model})...`);
let prediction = await createPrediction(apiToken, parsedModel, input, true);
if (prediction.status !== "succeeded") {
if (!prediction.urls?.get) {
throw new Error("Replicate prediction did not return a poll URL");
}
console.log("Waiting for prediction to complete...");
prediction = await pollPrediction(apiToken, prediction.urls.get);
}
console.log("Generation completed.");
const outputUrl = extractOutputUrl(prediction);
return downloadImage(outputUrl);
}
+2 -1
View File
@@ -1,4 +1,4 @@
export type Provider = "google" | "openai" | "dashscope"; export type Provider = "google" | "openai" | "dashscope" | "replicate";
export type Quality = "normal" | "2k"; export type Quality = "normal" | "2k";
export type CliArgs = { export type CliArgs = {
@@ -27,5 +27,6 @@ export type ExtendConfig = {
google: string | null; google: string | null;
openai: string | null; openai: string | null;
dashscope: string | null; dashscope: string | null;
replicate: string | null;
}; };
}; };
+26 -6
View File
@@ -1,6 +1,6 @@
--- ---
name: baoyu-infographic name: baoyu-infographic
description: Generates professional infographics with 20 layout types and 17 visual styles. Analyzes content, recommends layout×style combinations, and generates publication-ready infographics. Use when user asks to create "infographic", "信息图", "visual summary", or "可视化". description: Generates professional infographics with 21 layout types and 20 visual styles. Analyzes content, recommends layout×style combinations, and generates publication-ready infographics. Use when user asks to create "infographic", "信息图", "visual summary", "可视化", or "高密度信息大图".
--- ---
# Infographic Generator # Infographic Generator
@@ -20,8 +20,8 @@ Two dimensions: **layout** (information structure) × **style** (visual aestheti
| Option | Values | | Option | Values |
|--------|--------| |--------|--------|
| `--layout` | 20 options (see Layout Gallery), default: bento-grid | | `--layout` | 21 options (see Layout Gallery), default: bento-grid |
| `--style` | 17 options (see Style Gallery), default: craft-handmade | | `--style` | 20 options (see Style Gallery), default: craft-handmade |
| `--aspect` | landscape (16:9), portrait (9:16), square (1:1) | | `--aspect` | landscape (16:9), portrait (9:16), square (1:1) |
| `--lang` | en, zh, ja, etc. | | `--lang` | en, zh, ja, etc. |
@@ -49,6 +49,7 @@ Two dimensions: **layout** (information structure) × **style** (visual aestheti
| `venn-diagram` | Overlapping concepts | | `venn-diagram` | Overlapping concepts |
| `winding-roadmap` | Journey, milestones | | `winding-roadmap` | Journey, milestones |
| `circular-flow` | Cycles, recurring processes | | `circular-flow` | Cycles, recurring processes |
| `dense-modules` | High-density modules, data-rich guides |
Full definitions: `references/layouts/<layout>.md` Full definitions: `references/layouts/<layout>.md`
@@ -73,6 +74,9 @@ Full definitions: `references/layouts/<layout>.md`
| `ikea-manual` | Minimal line art | | `ikea-manual` | Minimal line art |
| `knolling` | Organized flat-lay | | `knolling` | Organized flat-lay |
| `lego-brick` | Toy brick construction | | `lego-brick` | Toy brick construction |
| `pop-laboratory` | Blueprint grid, coordinate markers, lab precision |
| `morandi-journal` | Hand-drawn doodle, warm Morandi tones |
| `retro-pop-grid` | 1970s retro pop art, Swiss grid, thick outlines |
Full definitions: `references/styles/<style>.md` Full definitions: `references/styles/<style>.md`
@@ -92,9 +96,23 @@ Full definitions: `references/styles/<style>.md`
| Educational | `bento-grid` + `chalkboard` | | Educational | `bento-grid` + `chalkboard` |
| Journey | `winding-roadmap` + `storybook-watercolor` | | Journey | `winding-roadmap` + `storybook-watercolor` |
| Categories | `periodic-table` + `bold-graphic` | | Categories | `periodic-table` + `bold-graphic` |
| Product Guide | `dense-modules` + `morandi-journal` |
| Technical Guide | `dense-modules` + `pop-laboratory` |
| Trendy Guide | `dense-modules` + `retro-pop-grid` |
Default: `bento-grid` + `craft-handmade` Default: `bento-grid` + `craft-handmade`
## Keyword Shortcuts
When user input contains these keywords, **auto-select** the associated layout and offer associated styles as top recommendations in Step 3. Skip content-based layout inference for matched keywords.
If a shortcut has **Prompt Notes**, append them to the generated prompt (Step 5) as additional style instructions.
| User Keyword | Layout | Recommended Styles | Default Aspect | Prompt Notes |
|--------------|--------|--------------------|----------------|--------------|
| 高密度信息大图 / high-density-info | `dense-modules` | `morandi-journal`, `pop-laboratory`, `retro-pop-grid` | portrait | — |
| 信息图 / infographic | `bento-grid` | `craft-handmade` | landscape | Minimalist: clean canvas, ample whitespace, no complex background textures. Simple cartoon elements and icons only. |
## Output Structure ## Output Structure
``` ```
@@ -176,7 +194,9 @@ See `references/structured-content-template.md` for detailed format.
### Step 3: Recommend Combinations ### Step 3: Recommend Combinations
Recommend 3-5 layout×style combinations based on: **3.1 Check Keyword Shortcuts first**: If user input matches a keyword from the **Keyword Shortcuts** table, auto-select the associated layout and prioritize associated styles as top recommendations. Skip content-based layout inference.
**3.2 Otherwise**, recommend 3-5 layout×style combinations based on:
- Data structure → matching layout - Data structure → matching layout
- Content tone → matching style - Content tone → matching style
- Audience expectations - Audience expectations
@@ -222,8 +242,8 @@ Report: topic, layout, style, aspect, language, output path, files created.
- `references/analysis-framework.md` - Analysis methodology - `references/analysis-framework.md` - Analysis methodology
- `references/structured-content-template.md` - Content format - `references/structured-content-template.md` - Content format
- `references/base-prompt.md` - Prompt template - `references/base-prompt.md` - Prompt template
- `references/layouts/<layout>.md` - 20 layout definitions - `references/layouts/<layout>.md` - 21 layout definitions
- `references/styles/<style>.md` - 17 style definitions - `references/styles/<style>.md` - 20 style definitions
## Extension Support ## Extension Support
@@ -38,7 +38,8 @@ Approach content analysis as a **world-class instructional designer**:
| **Cycle/Loop** | Recurring processes, feedback loops | circular-flow | craft-handmade, technical-schematic | | **Cycle/Loop** | Recurring processes, feedback loops | circular-flow | craft-handmade, technical-schematic |
| **System/Structure** | Components, architecture, anatomy | structural-breakdown, bento-grid | technical-schematic, ikea-manual | | **System/Structure** | Components, architecture, anatomy | structural-breakdown, bento-grid | technical-schematic, ikea-manual |
| **Journey/Narrative** | Stories, user flows, milestones | winding-roadmap, story-mountain | storybook-watercolor, comic-strip | | **Journey/Narrative** | Stories, user flows, milestones | winding-roadmap, story-mountain | storybook-watercolor, comic-strip |
| **Overview/Summary** | Multiple topics, feature highlights | bento-grid, periodic-table | chalkboard, bold-graphic | | **Overview/Summary** | Multiple topics, feature highlights | bento-grid, periodic-table, dense-modules | chalkboard, bold-graphic |
| **Product/Buying Guide** | Multi-dimension comparisons, specs, pitfalls | dense-modules | morandi-journal, pop-laboratory, retro-pop-grid |
### 2. Learning Objective Identification ### 2. Learning Objective Identification
@@ -0,0 +1,72 @@
# dense-modules
High-density modular layout with 6-7 typed information modules packed with concrete data.
## Structure
- 6-7 distinct modules per image, each serving a specific information function
- Every module contains concrete data: brand names, numbers, percentages, parameters
- Minimal whitespace—compact spacing prioritized over breathing room
- Smaller text acceptable to maximize information density
- Each module identified by coordinate label or section marker (e.g., MOD-1, SEC-A)
## Module Archetypes
| Module | Purpose | Content Requirements |
|--------|---------|---------------------|
| **Brand/Selection Array** | Grid of options with recommendations | 4-8 items with icons, names, brief descriptions; highlight "best choice" |
| **Specification Scale** | Quality/measurement gauge | 3-5 levels with precise numerical increments, quality indicators (emoji faces, checkmarks) |
| **Deep Dive/Detail** | Technical breakdown of key item | Zoom-in callouts, internal components, cross-section or exploded view |
| **Scenario Comparison** | Side-by-side use cases | 3-6 scenarios with specific recommendations and data per scenario |
| **Identification Tips** | How-to checklist | 3-5 inspection methods: look/test/check/ask format |
| **Warning/Pitfall Zone** | Critical mistakes to avoid | 3-5 pitfalls with consequences, 1-2 correct approaches; high visual contrast |
| **Quick Reference** | Compact summary | Dense table, one-line summaries, decision flowchart, or key takeaways |
## Variants
| Variant | Focus | Visual Emphasis |
|---------|-------|-----------------|
| **Coordinate-labeled** | Precision and systematicity | Each module has alphanumeric coordinate (A-01, B-05, C-12), ruler/axis markers |
| **Grid-cell** | Order and structure | Modules in strict rectangular cells divided by thick lines, Swiss grid feel |
| **Free-flowing** | Organic density | Magazine-style layout with dotted frames, varying module sizes, connected by arrows |
## Best For
- Product selection guides and buying guides
- Multi-dimensional comparison content
- Data-rich educational materials
- "Avoid pitfalls" / "complete guide" formats
- Content targeting platforms like Xiaohongshu with high-density visual requirements
## Visual Elements
- Module boundary markers (thick lines, dotted frames, or coordinate grids)
- Quality indicators per module (emoji faces, checkmarks, crosses, crowns)
- Data callout boxes with highlighted numbers
- Comparison arrows and progression indicators
- Warning/alert visual markers for pitfall modules
- Metadata in corners (page numbers, timestamps, small barcodes)
## Text Placement
- Main title at top, prominent and impactful
- Subtitle with module count ("X大维度全面解析...")
- Module headers inside colored badges or labeled frames
- Body text compact, multiple columns within modules
- Numbers highlighted with accent colors, slightly larger than body text
## Information Density Rules
- Every corner should contain useful information or metadata
- No decorative-only empty space
- Text size may be reduced to fit more content—information over font size
- Each module must have specific data points, not generic descriptions
- Balance between density and readability: dense but organized
## Recommended Pairings
- `pop-laboratory`: Technical precision with coordinate markers and blueprint grid
- `morandi-journal`: Hand-drawn warmth with doodle illustrations and organic frames
- `retro-pop-grid`: 1970s pop art with strict grid cells and bold contrast
- `corporate-memphis`: Clean business feel for product comparisons
- `technical-schematic`: Engineering precision for technical product guides
@@ -0,0 +1,60 @@
# morandi-journal
Hand-drawn doodle illustration with warm Morandi color tones and cozy bullet journal aesthetic.
## Color Palette
- Background: Warm cream/beige with subtle paper texture (#F5F0E6)
- Primary: Muted teal/sage green (#7BA3A8) for headers and frames
- Secondary: Warm terracotta/orange (#D4956A) for highlights and numbers
- Line art: Dark charcoal brown (#4A4540)
- Soft highlights: Pale yellow (#F5E6C8)
## Visual Elements
- Hand-drawn doodle illustrations with organic, slightly imperfect ink lines
- Washi tape strip decorations (diagonal stripes pattern, beige and brown)
- Rounded card containers for brand/option items
- Hand-drawn rulers, scales, and progress bars with emoji quality indicators
- Smiley/frowny faces as quality markers (😊✓ 😐 ☹️✗)
- Dotted line frames around sections
- Connecting arrows and dotted lines between modules
- Corner decorations: tiny houses, stars, sparkles, clouds
- Wavy line dividers between sections
- Callout bubbles for tips
- Magnifying glass icons for identification tips
- Thumbs up/down icons (hand-drawn style)
## Variants
| Variant | Focus | Visual Emphasis |
|---------|-------|-----------------|
| **Cozy journal** | Maximum warmth | More washi tape, stickers, decorative doodles |
| **Clean sketch** | Readability | Cleaner lines, less decoration, more structured |
## Typography
- Main title: Bold hand-lettered calligraphy style with decorative flourishes
- Module headers: Clean handwritten text in white on dark teal rounded badge (#6B9080)
- Body text: Neat handwritten print style, easy to read
- Numbers: Highlighted in terracotta (#D4956A), slightly larger than body
## Style Enforcement
- All imagery must maintain hand-drawn/doodle aesthetic—no digital precision
- Organic, slightly imperfect shapes throughout
- Sketch-like quality with visible line weight variations
- Warm and cozy journal feel, not clinical or corporate
## Avoid
- Flat vector icons or emoji
- Clean geometric shapes
- Stock illustration style
- Strict grid layout
- Pure white background
- Digital/corporate look
## Best For
Product selection guides, lifestyle content, educational overviews, consumer-facing comparison content, Xiaohongshu-style posts
@@ -0,0 +1,48 @@
# pop-laboratory
Lab manual precision meets pop art color impact—coordinate systems, technical diagrams, and fluorescent accents on blueprint grid.
## Color Palette
- Background: Professional grayish-white with faint blueprint grid texture (#F2F2F2)
- Primary: Muted teal/sage green (#B8D8BE) for major functional blocks and data zones
- High-alert accent: Vibrant fluorescent pink (#E91E63) strictly for warnings, critical data, or "winner" highlights
- Marker highlights: Vivid lemon yellow (#FFF200) as translucent highlighter effect for keywords
- Line art: Ultra-fine charcoal brown (#2D2926) for technical grids, coordinates, and hairlines
## Visual Elements
- Coordinate-style labels on every module (e.g., R-20, G-02, SEC-08)
- Technical diagrams: exploded views, cross-sections with anchor points, architectural skeletal lines
- Vertical/horizontal rulers with precise markers (0.5mm, 1.8mm, 45°)
- "Marker-over-print" effect: color blocks slightly offset from text, postmodern print feel
- Cross-hair targets, mathematical symbols (Σ, Δ, ∞), directional arrows (X/Y axis)
- Microscopic detail annotations alongside macroscopic bold headers
- Corner metadata: tiny barcodes, timestamps, technical parameters
- High contrast between massive bold headers and tiny 8pt-style annotations
## Typography
- Headers: Bold brutalist characters, high visual impact
- Body: Professional sans-serif or crisp technical print
- Numbers: Large, highlighted with yellow or blue to stand out
- Annotations: Ultra-crisp, small technical labels
## Style Enforcement
- Strictly systematic color usage: only teal, pink, yellow, charcoal—no rainbow palette
- Sufficient fine grid lines and coordinate annotations throughout
- Maintain tension between large impactful headers and small precise parameters
- Lab manual aesthetic: mix of microscopic details and macroscopic data
## Avoid
- Cute or cartoonish doodles
- Soft pastels or generic textures
- Empty white space
- Flat vector stock icons
- Organic or hand-drawn imperfections
## Best For
Technical product guides, specification comparisons, precision-focused data visualization, engineering-adjacent content
@@ -0,0 +1,47 @@
# retro-pop-grid
1970s retro pop art with strict Swiss international grid, thick black outlines, and flat color blocks.
## Color Palette
- Background: Warm vintage cream/beige (#F5F0E6)
- Flat accents: Salmon pink, sky blue, mustard yellow, mint green—all muted retro tones
- Contrast blocks: Solid pure black (#000000) and solid pure white (#FFFFFF) used strategically for extreme contrast
- Line art and outlines: Solid thick black
## Visual Elements
- Uniform thick black outlines on all illustrations, text boxes, and grid dividers
- Pure 2D flat vector aesthetic with subtle screen print texture
- Strict Swiss international grid: poster divided into square and rectangular cells by thick black lines
- Black-background cells with white text for warnings or key categories (inverted contrast)
- Geometric fill patterns in empty cells: checkerboards, diagonal lines, dots
- Flat abstract symbols, warning signs, keyholes, stars, arrows
- Vintage comic-style smiley/frowny faces for quality indicators
- Colored cells used for breathing room—some with minimal/no content
## Typography
- Headers: Bold brutalist or retro thick display fonts, high legibility
- Body: Clean sans-serif, structured typographic alignment
- Decorative English text acceptable for stylistic labels ("WARNING", "INFO", "BEST")
- All content text in specified language
## Style Enforcement
- Absolutely no gradients, shading, drop shadows, or 3D effects
- Everything anchored in grid cells—no floating or unorganized elements
- Maintain 1970s retro pop art and underground comic illustration feel
- Visual density balanced with rhythmic grid—some cells intentionally sparse for contrast
## Avoid
- 3D rendering, realistic details, gradients, soft shadows
- Soft, thin, or sketch-like pencil lines
- Free-flowing, unorganized, or floating layouts (everything must be grid-anchored)
- Pure white background canvas
- Organic or hand-drawn imperfections
## Best For
Trendy product guides, design-conscious content, visually striking comparisons, content targeting design-savvy audiences, bold social media posts
+17 -2
View File
@@ -68,9 +68,24 @@ Use `AskUserQuestion` to ask whether to format first. Formatting can fix:
**If user declines**: Continue with original file. **If user declines**: Continue with original file.
### Step 1: Confirm Theme ### Step 1: Determine Theme
Before converting, use AskUserQuestion to confirm the theme (unless user already specified): **Theme resolution order** (first match wins):
1. User explicitly specified theme (CLI `--theme` or conversation)
2. EXTEND.md `default_theme` (this skill's own EXTEND.md, checked in Step 0)
3. `baoyu-post-to-wechat` EXTEND.md `default_theme` (cross-skill fallback)
4. If none found → use AskUserQuestion to confirm
**Cross-skill EXTEND.md check** (only if this skill's EXTEND.md has no `default_theme`):
```bash
# Check baoyu-post-to-wechat EXTEND.md for default_theme
test -f "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md" && grep -o 'default_theme:.*' "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md"
```
**If theme is resolved from EXTEND.md**: Use it directly, do NOT ask the user.
**If no default found**: Use AskUserQuestion to confirm:
| Theme | Description | | Theme | Description |
|-------|-------------| |-------|-------------|
+94 -13
View File
@@ -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 |
@@ -138,20 +196,19 @@ B) Continue - provide HTML file manually
**Skip if**: Input is `.html` file **Skip if**: Input is `.html` file
1. **Ask theme preference** (unless specified in EXTEND.md or CLI): 1. **Resolve theme** (first match wins, do NOT ask user if resolved):
- CLI `--theme` argument
- EXTEND.md `default_theme` (loaded in Step 0)
- Fallback: `default`
| Theme | Description | 2. **Execute conversion** (using the discovered skill), **always pass `--theme`**:
|-------|-------------|
| `default` | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 |
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 |
2. **Execute conversion** (using the discovered skill):
```bash ```bash
npx -y bun ${MD_TO_HTML_SKILL_DIR}/scripts/main.ts <markdown_file> --theme <theme> npx -y bun ${MD_TO_HTML_SKILL_DIR}/scripts/main.ts <markdown_file> --theme <theme>
``` ```
**CRITICAL**: Always include `--theme` parameter. Never omit it, even if using `default`.
3. **Parse JSON output** to get: `htmlPath`, `title`, `author`, `summary`, `contentImages` 3. **Parse JSON output** to get: `htmlPath`, `title`, `author`, `summary`, `contentImages`
### Step 4: Validate Metadata ### Step 4: Validate Metadata
@@ -162,11 +219,19 @@ Check extracted metadata from Step 3 (or HTML meta tags if direct HTML input).
|-------|------------| |-------|------------|
| Title | Prompt: "Enter title, or press Enter to auto-generate from content" | | 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 +278,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 +313,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 +366,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 +401,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> {
@@ -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,24 +592,45 @@ 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}`);
if (contentStatus === 'editor_not_found') {
throw new Error('Content editor not found');
}
// Fallback: old editor uses keyboard simulation
if (contentStatus.startsWith('{')) {
const editorClickPos = JSON.parse(contentStatus);
if (editorClickPos.type === 'old') {
console.log('[wechat-browser] Using old editor with keyboard simulation...');
await cdp.send('Input.dispatchMouseEvent', { await cdp.send('Input.dispatchMouseEvent', {
type: 'mousePressed', type: 'mousePressed',
x: editorClickPos.x, x: editorClickPos.x,
@@ -596,11 +648,10 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
}, { sessionId }); }, { sessionId });
await sleep(300); await sleep(300);
console.log('[wechat-browser] Typing content with keyboard simulation...');
const lines = content.split('\n'); const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const line = lines[i]; const line = lines[i];
if (line.length > 0) { if (line!.length > 0) {
await cdp.send('Input.insertText', { text: line }, { sessionId }); await cdp.send('Input.insertText', { text: line }, { sessionId });
} }
if (i < lines.length - 1) { if (i < lines.length - 1) {
@@ -619,15 +670,56 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
} }
await sleep(50); await sleep(50);
} }
console.log('[wechat-browser] Content typed.'); console.log('[wechat-browser] Content typed via keyboard.');
}
}
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]
+50 -15
View File
@@ -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
+140
View File
@@ -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> {
+186 -170
View File
@@ -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 };
} }
frontmatter[key] = value; }
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;
}
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, '&amp;') .replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
.replace(/>/g, '&gt;') .replace(/>/g, '&gt;')
.replace(/"/g, '&quot;'); .replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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) { const totalBlocks = blockTokens.filter((token) => {
codeBlockContent.push(line); if (token.type === 'space') return false;
continue; if (token.type === 'heading' && token.depth === 1) return false;
} return true;
}).length;
// Empty line
if (line.trim() === '') {
flushList();
continue;
}
// Image
const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/);
if (imgMatch) {
flushList();
const placeholder = imageCallback(imgMatch[2]!, imgMatch[1]!);
blocks.push(`<p>${placeholder}</p>`);
continue;
}
// Heading (H1 is title, skip it; H2-H6 become H2)
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
flushList();
const level = headingMatch[1]!.length;
if (level === 1) continue; // Skip H1, it's the title
blocks.push(`<h2>${processInline(headingMatch[2]!)}</h2>`);
continue;
}
// Blockquote
if (line.startsWith('> ')) {
flushList();
blocks.push(`<blockquote>${processInline(line.slice(2))}</blockquote>`);
continue;
}
// Unordered list
const ulMatch = line.match(/^[-*]\s+(.+)$/);
if (ulMatch) {
if (!inList || listType !== 'ul') {
flushList();
inList = true;
listType = 'ul';
}
listItems.push(processInline(ulMatch[1]!));
continue;
}
// Ordered list
const olMatch = line.match(/^\d+\.\s+(.+)$/);
if (olMatch) {
if (!inList || listType !== 'ol') {
flushList();
inList = true;
listType = 'ol';
}
listItems.push(processInline(olMatch[1]!));
continue;
}
// Horizontal rule
if (/^[-*_]{3,}\s*$/.test(line)) {
flushList();
blocks.push('<hr>');
continue;
}
// Regular paragraph
flushList();
blocks.push(`<p>${processInline(line)}</p>`);
}
flushList();
return { 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 (const line of htmlLines) {
for (let i = 0; i < images.length; i++) { for (let i = 0; i < images.length; i++) {
const placeholder = `XIMGPH_${i + 1}`; const placeholder = `XIMGPH_${i + 1}`;
if (line.includes(placeholder)) { for (let lineIndex = 0; lineIndex < htmlLines.length; lineIndex++) {
images[i]!.blockIndex = blockIdx; const regex = new RegExp(`\\b${placeholder}\\b`);
if (regex.test(htmlLines[lineIndex]!)) {
images[i]!.blockIndex = lineIndex;
break;
} }
} }
blockIdx++;
} }
// Resolve image paths (download remote, resolve relative)
const contentImages: ImageInfo[] = []; 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"
}
}
+41 -6
View File
@@ -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]) {
+27 -2
View File
@@ -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) {
+2 -1
View File
@@ -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 {
+13 -2
View File
@@ -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.