mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 05:51:44 +08:00
Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2188160418 | |||
| 77204d2e4a | |||
| 35eeb62f10 | |||
| 7dfa2b6e6c | |||
| a2eecec26f | |||
| 903e7ad79d | |||
| 13707fa2cf | |||
| b56e503b16 | |||
| 7d03685ade | |||
| b305c386bc | |||
| 240dd7d314 | |||
| b02ceacfd9 | |||
| fdf9007e2c | |||
| 08cee885d3 | |||
| 3bd5fdeb1b | |||
| e737c4a611 | |||
| 2eec4f3639 | |||
| e5912018f3 | |||
| b1f568d03d | |||
| e736707628 | |||
| d863f11f61 | |||
| 2ce873c65c | |||
| 964cf1e045 | |||
| eded9a98bb | |||
| a64fdbd23f | |||
| 36b9c5e197 | |||
| 851497abbd | |||
| 65a561e654 | |||
| 7b2c02a007 | |||
| 98f49eae96 | |||
| 1bdf44df9e | |||
| d59ecf22c1 | |||
| 22fa1a62fc | |||
| 3d48eed33a | |||
| 8f1c4a65dd | |||
| 9b97720f16 | |||
| 145e1d2d04 | |||
| 49403b6fab | |||
| 6d56f1dae8 | |||
| dd08a2aa89 | |||
| d9b47debb3 | |||
| 6cc8627ca8 | |||
| 54103dfd7d | |||
| 6012b9cc88 | |||
| 260b71cdd7 | |||
| 9ff468a6a7 | |||
| c83d114a73 | |||
| e964100dd9 | |||
| 6c0ae7a86a | |||
| 52ade637af | |||
| 569beebdd6 | |||
| 6cbf0f4e52 | |||
| 5615e6150f | |||
| 7465f37dcf | |||
| 38a1b90db3 | |||
| b53a5db60a | |||
| 372e03cef3 | |||
| ea8ad82786 | |||
| 6fffe252c0 | |||
| 86a84739e8 | |||
| 7f80100b3e | |||
| 61960961ba | |||
| cdd9ee042d | |||
| c742bfa1af | |||
| 0faea4ecaa | |||
| 7c9ec259a1 | |||
| 24a17709a8 | |||
| a3849af0cf | |||
| cc95e6fe05 | |||
| e7d9ed7917 | |||
| 876c6332f6 | |||
| 502d2448b2 | |||
| b9e48d9483 | |||
| b0554f8d0c | |||
| 12f3d95639 | |||
| 809fb29ca9 | |||
| be20bdf0be | |||
| 7c36b2a6a5 | |||
| 7979cb9dde | |||
| e563bbae7b | |||
| c63d1eec69 | |||
| 78d6d23359 | |||
| ec6781a231 | |||
| edf9030675 | |||
| 10e1c426a0 | |||
| dbab83ff82 | |||
| 478e66a93a | |||
| 3a5866eb4b | |||
| c0162bb3af | |||
| e9342a16f8 | |||
| 5cb86d8e51 | |||
| 66ed08f010 | |||
| b179166d28 | |||
| 4805526649 | |||
| 89159bf86e | |||
| b45b5d2c6e | |||
| 62866426c5 | |||
| 28a7db6129 | |||
| 7c5e81fd15 | |||
| 058d25d02c | |||
| fe09ec546f | |||
| cf611a4514 |
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "1.25.1"
|
||||
"version": "1.40.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
+3
-1
@@ -156,4 +156,6 @@ comic/
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
*.ipr
|
||||
|
||||
.claude/skills/baoyu-skill-evolution
|
||||
|
||||
+214
@@ -2,6 +2,220 @@
|
||||
|
||||
English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 1.40.0 - 2026-02-28
|
||||
|
||||
### Features
|
||||
- `baoyu-image-gen`: support OpenAI chat completions endpoint for image generation (by @zhao-newname)
|
||||
- `baoyu-markdown-to-html`: add CLI customization options (--color, --font-family, --font-size, --code-theme, --mac-code-block, --line-number, --cite, --count, --legend) and EXTEND.md config support
|
||||
|
||||
## 1.39.0 - 2026-02-28
|
||||
|
||||
### Features
|
||||
- `baoyu-markdown-to-html`: add red theme (traditional calligraphy style with red-gold palette and serif typography) and orange theme (warm modern style with rounded corners and relaxed line height)
|
||||
|
||||
## 1.38.0 - 2026-02-28
|
||||
|
||||
### Features
|
||||
- `baoyu-danger-x-to-markdown`: render embedded tweets in articles as blockquotes with author info and text summary
|
||||
- `baoyu-danger-x-to-markdown`: reuse existing markdown when `--download-media` targets already-converted URLs
|
||||
- `baoyu-danger-x-to-markdown`: upgrade Twitter image downloads to 4096x4096 high resolution
|
||||
|
||||
### Fixes
|
||||
- `baoyu-danger-x-to-markdown`: improve entity resolution with logical key lookup for reliable media and link mapping
|
||||
- `baoyu-danger-x-to-markdown`: support trailing media for all block types (headings, lists, blockquotes)
|
||||
|
||||
## 1.37.1 - 2026-02-27
|
||||
|
||||
### Fixes
|
||||
- `baoyu-danger-gemini-web`: sync model headers with upstream and update model list (by @xkcoding)
|
||||
|
||||
## 1.37.0 - 2026-02-27
|
||||
|
||||
### Features
|
||||
- `baoyu-danger-x-to-markdown`: add inline link rendering for X article content, mapping LINK/MEDIA entities to markdown links
|
||||
- `baoyu-danger-x-to-markdown`: use content-based slug in output directory path for meaningful folder names
|
||||
- `baoyu-danger-x-to-markdown`: add atomic media queue for blocks without direct media references
|
||||
|
||||
## 1.36.0 - 2026-02-27
|
||||
|
||||
### Features
|
||||
- `baoyu-image-gen`: add `gemini-3.1-flash-image-preview` model support for Google multimodal image generation
|
||||
- `baoyu-image-gen`: improve first-time setup with blocking preferences flow and guided configuration
|
||||
|
||||
### Fixes
|
||||
- `baoyu-image-gen`: use curl fallback for Google API when HTTP proxy is detected (by @liye71023326)
|
||||
|
||||
## 1.35.0 - 2026-02-24
|
||||
|
||||
### Features
|
||||
- `baoyu-image-gen`: add Replicate provider support with configurable models (by @justnode)
|
||||
- `baoyu-infographic`: add `dense-modules` layout and 3 new styles (`morandi-journal`, `pop-laboratory`, `retro-pop-grid`) for high-density infographics. Add keyword shortcuts for auto-selection. Prompt credit: [AJ](https://waytoagi.feishu.cn/wiki/YG0zwalijihRREkgmPzcWRInnUg)
|
||||
|
||||
### Documentation
|
||||
- `baoyu-image-gen`: add Replicate model configuration documentation
|
||||
|
||||
## 1.34.2 - 2026-02-25
|
||||
|
||||
### Documentation
|
||||
- `baoyu-markdown-to-html`: clarify theme resolution order with local and cross-skill EXTEND.md fallbacks before prompting user.
|
||||
- `baoyu-post-to-wechat`: align markdown conversion theme handling with deterministic fallback (`CLI --theme` -> EXTEND.md `default_theme` -> `default`) and require explicit `--theme` parameter.
|
||||
|
||||
## 1.34.1 - 2026-02-20
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-wechat`: fix upload progress check crashing on second iteration (by @LyInfi)
|
||||
|
||||
## 1.34.0 - 2026-02-17
|
||||
|
||||
### Features
|
||||
- `baoyu-xhs-images`: add reference image chain for visual consistency across multi-image series (by @jeffrey94)
|
||||
|
||||
### Refactor
|
||||
- `baoyu-article-illustrator`: enforce prompt file creation as blocking step before image generation, add structured prompt quality requirements (ZONES / LABELS / COLORS / STYLE / ASPECT) and verification checklist.
|
||||
|
||||
## 1.33.1 - 2026-02-14
|
||||
|
||||
### Refactor
|
||||
- `baoyu-post-to-x`: replace hand-rolled markdown parser with marked ecosystem for X Articles HTML conversion.
|
||||
|
||||
### Documentation
|
||||
- `baoyu-post-to-x`: remove `--submit` flag from all scripts; clarify that scripts only fill content into browser for manual review and publish.
|
||||
|
||||
## 1.33.0 - 2026-02-13
|
||||
|
||||
### Features
|
||||
- `baoyu-post-to-x`: add pre-flight environment check script (`check-paste-permissions.ts`); add troubleshooting section for Chrome debug port conflicts; replace fixed sleep with image upload verification polling up to 15s.
|
||||
- `baoyu-post-to-wechat`: add pre-flight environment check script (`check-permissions.ts`) covering Chrome, profile isolation, Bun, Accessibility, clipboard, paste keystroke, API credentials.
|
||||
|
||||
## 1.32.0 - 2026-02-12
|
||||
|
||||
### Features
|
||||
- `baoyu-danger-x-to-markdown`: add `--download-media` flag to download images/videos locally and rewrite markdown links to relative paths; add media localization module; add first-time setup with EXTEND.md preferences; add `coverImage` to frontmatter output.
|
||||
|
||||
### Refactor
|
||||
- `baoyu-danger-x-to-markdown`: use camelCase for frontmatter keys (`tweetCount`, `coverImage`, `requestedUrl`, etc.).
|
||||
- `baoyu-format-markdown`: rename `featureImage` to `coverImage` as primary frontmatter key (with `featureImage` as accepted alias).
|
||||
- `baoyu-post-to-wechat`: prioritize `coverImage` over `featureImage` in cover image frontmatter lookup order.
|
||||
|
||||
## 1.31.2 - 2026-02-10
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-wechat`: fix PowerShell clipboard copy failing on Windows due to `param()`/`-Path` not working with `-Command`.
|
||||
- `baoyu-post-to-x`: fix PowerShell clipboard copy on Windows (same issue); fix `getScriptDir()` returning invalid path on Windows (`/C:/...` prefix).
|
||||
|
||||
## 1.31.1 - 2026-02-10
|
||||
|
||||
### Features
|
||||
- `baoyu-post-to-wechat`: adapt to new WeChat UI — rename 图文 to 贴图; add ProseMirror editor support with old editor fallback; add fallback file input selector; add upload progress monitoring; improve save button detection with toast verification.
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-wechat`: truncate digest > 120 chars at punctuation boundary; fix cover image relative path resolution.
|
||||
- `baoyu-post-to-x`: fix Chrome launch on macOS via `open -na`; fix cover image relative path resolution.
|
||||
|
||||
## 1.31.0 - 2026-02-07
|
||||
|
||||
### Features
|
||||
- `baoyu-post-to-wechat`: add comment control settings (`need_open_comment`, `only_fans_can_comment`); add cover image fallback chain (CLI → frontmatter → `imgs/cover.png` → first inline image); add author resolution priority; add first-time setup flow with EXTEND.md preferences.
|
||||
|
||||
## 1.30.3 - 2026-02-06
|
||||
|
||||
### Refactor
|
||||
- `baoyu-article-illustrator`: optimize SKILL.md from 197 to 150 lines (24% reduction); apply progressive disclosure pattern with concise overview and detailed references.
|
||||
|
||||
## 1.30.2 - 2026-02-06
|
||||
|
||||
### Refactor
|
||||
- `baoyu-cover-image`: optimize SKILL.md from 532 to 233 lines (56% reduction); extract reference image handling to `references/workflow/reference-images.md`; condense galleries to value-only tables with links.
|
||||
|
||||
## 1.30.1 - 2026-02-06
|
||||
|
||||
### Features
|
||||
- `baoyu-image-gen`: add OpenAI GPT Image edits support for reference images (`--ref`); auto-select Google or OpenAI when ref provided.
|
||||
|
||||
### Fixes
|
||||
- `baoyu-image-gen`: change ref-related warnings to explicit errors with fix hints; add reference image validation.
|
||||
- `baoyu-cover-image`: enhance reference image analysis with deep extraction template; require MUST INCORPORATE section for concrete visual elements.
|
||||
|
||||
## 1.30.0 - 2026-02-06
|
||||
|
||||
### Features
|
||||
- `baoyu-cover-image`: add font dimension with 4 typography styles (clean, handwritten, serif, display); includes auto-selection rules, compatibility matrix, and `warm-flat` style preset.
|
||||
|
||||
## 1.29.0 - 2026-02-06
|
||||
|
||||
### Features
|
||||
- `baoyu-image-gen`: add EXTEND.md configuration support, including schema documentation and runtime preference loading in scripts (by @kingdomad).
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-wechat`: fix duplicated title and ordered-list numbering in WeChat article publishing (by @NantesCheval).
|
||||
- `baoyu-url-to-markdown`: replace regex-only conversion with multi-strategy content extraction and Turndown conversion; improve noise filtering for Substack-style pages.
|
||||
|
||||
## 1.28.4 - 2026-02-03
|
||||
|
||||
### Features
|
||||
- `baoyu-markdown-to-html`: add author and description meta tags to generated HTML from YAML frontmatter; strip quotes from frontmatter values (supports both English and Chinese quotation marks).
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-wechat`: remove extra empty lines after image paste; fix summary field timing to fill after content paste (prevents being overwritten).
|
||||
|
||||
## 1.28.3 - 2026-02-03
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-wechat`: fix placeholder matching issue where `WECHATIMGPH_1` incorrectly matched `WECHATIMGPH_10`.
|
||||
|
||||
## 1.28.2 - 2026-02-03
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-x`: reuse existing Chrome instance when available; fix placeholder matching issue where `XIMGPH_1` incorrectly matched `XIMGPH_10`; improve image sorting by placeholder index; use `execCommand` for more reliable placeholder deletion.
|
||||
|
||||
## 1.28.1 - 2026-02-02
|
||||
|
||||
### Refactor
|
||||
- `baoyu-article-illustrator`: simplify main SKILL.md by extracting detailed procedures to `workflow.md`; add Core Styles tier (vector, minimal-flat, sci-fi, hand-drawn, editorial, scene) for quick selection; add `vector-illustration` as recommended default style; add Illustration Purpose (information/visualization/imagination) for better type/style recommendations; add default composition requirements, character rendering guidelines, and text styling rules to prompt construction.
|
||||
|
||||
## 1.28.0 - 2026-02-01
|
||||
|
||||
### Features
|
||||
- `baoyu-cover-image`: add reference image support (`--ref` parameter) with direct/style/palette usage types; add visual elements library with icon vocabulary by topic.
|
||||
- `baoyu-article-illustrator`: add reference image support with direct/style/palette usage types.
|
||||
- `baoyu-post-to-wechat`: add `newspic` article type for image-text posts.
|
||||
|
||||
### Refactor
|
||||
- `baoyu-cover-image`, `baoyu-article-illustrator`, `baoyu-comic`, `baoyu-xhs-images`: enforce first-time setup as blocking operation before any other workflow steps.
|
||||
- `baoyu-cover-image`: remove character limits from titles, use original source titles.
|
||||
|
||||
## 1.26.1 - 2026-01-29
|
||||
|
||||
### Features
|
||||
- `baoyu-article-illustrator`, `baoyu-comic`, `baoyu-cover-image`, `baoyu-infographic`, `baoyu-slide-deck`, `baoyu-xhs-images`: add backup rules for existing files—automatically renames source, prompt, and image files with timestamp suffix before overwriting.
|
||||
|
||||
### Fixes
|
||||
- `baoyu-xhs-images`: remove `notebook` style (10 styles remaining).
|
||||
|
||||
## 1.26.0 - 2026-01-29
|
||||
|
||||
### Features
|
||||
- `baoyu-xhs-images`: add `notebook` style (hand-drawn infographic with watercolor rendering and Morandi palette) and `study-notes` style (realistic handwritten photo aesthetic).
|
||||
- `baoyu-xhs-images`: add `mindmap` (center radial) and `quadrant` (four-section grid) layouts.
|
||||
|
||||
## 1.25.4 - 2026-01-29
|
||||
|
||||
### Fixes
|
||||
- `baoyu-markdown-to-html`: generate proper `<img>` tags with `data-local-path` attribute instead of text placeholders.
|
||||
- `baoyu-post-to-wechat`: fix API publishing to read image paths from `data-local-path` attribute; fix title/cover extraction from corresponding `.md` frontmatter when publishing HTML files.
|
||||
- `baoyu-post-to-wechat`: fix CLI argument parsing to handle unknown parameters gracefully; add `--summary` parameter support.
|
||||
- `baoyu-post-to-wechat`: fix browser publishing to convert `<img>` tags back to text placeholders before paste.
|
||||
|
||||
## 1.25.3 - 2026-01-28
|
||||
|
||||
### Features
|
||||
- `baoyu-format-markdown`: add content type detection with user confirmation for markdown files; add CJK punctuation handling to move paired punctuation outside emphasis markers.
|
||||
|
||||
## 1.25.2 - 2026-01-28
|
||||
|
||||
### Documentation
|
||||
- `baoyu-post-to-wechat`: add WeChat API credentials configuration guide to README.
|
||||
|
||||
## 1.25.1 - 2026-01-28
|
||||
|
||||
### Features
|
||||
|
||||
+214
@@ -2,6 +2,220 @@
|
||||
|
||||
[English](./CHANGELOG.md) | 中文
|
||||
|
||||
## 1.40.0 - 2026-02-28
|
||||
|
||||
### 新功能
|
||||
- `baoyu-image-gen`:支持 OpenAI Chat Completions 端点生成图片 (by @zhao-newname)
|
||||
- `baoyu-markdown-to-html`:新增 CLI 自定义选项(--color、--font-family、--font-size、--code-theme、--mac-code-block、--line-number、--cite、--count、--legend)及 EXTEND.md 配置支持
|
||||
|
||||
## 1.39.0 - 2026-02-28
|
||||
|
||||
### 新功能
|
||||
- `baoyu-markdown-to-html`:新增红色主题(红金配色、宋体排版、传统书法风格)和橙色主题(暖色调现代风、圆角装饰、宽松行距)
|
||||
|
||||
## 1.38.0 - 2026-02-28
|
||||
|
||||
### 新功能
|
||||
- `baoyu-danger-x-to-markdown`:支持文章内嵌推文渲染,以引用块形式显示作者信息和推文摘要
|
||||
- `baoyu-danger-x-to-markdown`:`--download-media` 复用已转换的 Markdown 文件,跳过重复抓取
|
||||
- `baoyu-danger-x-to-markdown`:推特图片下载升级至 4096x4096 高分辨率
|
||||
|
||||
### 修复
|
||||
- `baoyu-danger-x-to-markdown`:改进实体解析逻辑,通过逻辑键查找提升媒体和链接映射准确性
|
||||
- `baoyu-danger-x-to-markdown`:所有区块类型(标题、列表、引用块)支持尾随媒体展示
|
||||
|
||||
## 1.37.1 - 2026-02-27
|
||||
|
||||
### 修复
|
||||
- `baoyu-danger-gemini-web`:同步上游模型请求头并更新模型列表 (by @xkcoding)
|
||||
|
||||
## 1.37.0 - 2026-02-27
|
||||
|
||||
### 新功能
|
||||
- `baoyu-danger-x-to-markdown`:支持 X 文章内联链接渲染,将 LINK/MEDIA 实体映射为 Markdown 链接
|
||||
- `baoyu-danger-x-to-markdown`:输出目录使用基于内容的 slug,生成更有意义的文件夹名称
|
||||
- `baoyu-danger-x-to-markdown`:新增 atomic 媒体队列,支持无直接媒体引用的区块
|
||||
|
||||
## 1.36.0 - 2026-02-27
|
||||
|
||||
### 新功能
|
||||
- `baoyu-image-gen`:新增 `gemini-3.1-flash-image-preview` Google 多模态图片生成模型支持
|
||||
- `baoyu-image-gen`:优化首次使用引导流程,支持阻塞式偏好配置
|
||||
|
||||
### 修复
|
||||
- `baoyu-image-gen`:检测到 HTTP 代理时自动回退使用 curl 调用 Google API (by @liye71023326)
|
||||
|
||||
## 1.35.0 - 2026-02-24
|
||||
|
||||
### 新功能
|
||||
- `baoyu-image-gen`:新增 Replicate 图片生成服务,支持自定义模型配置 (by @justnode)
|
||||
- `baoyu-infographic`:新增 `dense-modules` 高密度模块布局及 3 种新风格(`morandi-journal`、`pop-laboratory`、`retro-pop-grid`),支持关键词快捷选择。高密度信息大图提示词来自 [AJ](https://waytoagi.feishu.cn/wiki/YG0zwalijihRREkgmPzcWRInnUg)
|
||||
|
||||
### 文档
|
||||
- `baoyu-image-gen`:补充 Replicate 模型配置说明文档
|
||||
|
||||
## 1.34.2 - 2026-02-25
|
||||
|
||||
### 文档
|
||||
- `baoyu-markdown-to-html`:明确主题解析优先级,先读取本技能与跨技能 EXTEND.md 的 `default_theme`,仅在未命中时询问用户。
|
||||
- `baoyu-post-to-wechat`:统一 markdown 转 HTML 的主题解析回退链(CLI `--theme` -> EXTEND.md `default_theme` -> `default`),并强制始终显式传入 `--theme` 参数。
|
||||
|
||||
## 1.34.1 - 2026-02-20
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-wechat`:修复上传进度检查在第二次迭代时崩溃的问题 (by @LyInfi)
|
||||
|
||||
## 1.34.0 - 2026-02-17
|
||||
|
||||
### 新功能
|
||||
- `baoyu-xhs-images`:新增参考图片链功能,确保多图系列的视觉一致性 (by @jeffrey94)
|
||||
|
||||
### 重构
|
||||
- `baoyu-article-illustrator`:将提示词文件创建设为生成图片前的阻断步骤,新增结构化提示词质量要求(ZONES / LABELS / COLORS / STYLE / ASPECT)和验证清单。
|
||||
|
||||
## 1.33.1 - 2026-02-14
|
||||
|
||||
### 重构
|
||||
- `baoyu-post-to-x`:将手写 markdown 解析器替换为 marked 生态系统,用于 X Articles HTML 转换。
|
||||
|
||||
### 文档
|
||||
- `baoyu-post-to-x`:移除所有脚本的 `--submit` 参数;明确脚本仅将内容填充到浏览器,由用户手动审核和发布。
|
||||
|
||||
## 1.33.0 - 2026-02-13
|
||||
|
||||
### 新功能
|
||||
- `baoyu-post-to-x`:新增环境预检脚本(`check-paste-permissions.ts`);新增 Chrome 调试端口冲突的故障排查说明;将固定等待替换为图片上传轮询验证(最长 15 秒)。
|
||||
- `baoyu-post-to-wechat`:新增环境预检脚本(`check-permissions.ts`),检查 Chrome、配置文件隔离、Bun、辅助功能、剪贴板、粘贴按键和 API 凭据。
|
||||
|
||||
## 1.32.0 - 2026-02-12
|
||||
|
||||
### 新功能
|
||||
- `baoyu-danger-x-to-markdown`:新增 `--download-media` 参数,支持将图片/视频下载到本地并将 markdown 链接改写为相对路径;新增媒体本地化模块;新增首次使用 EXTEND.md 偏好设置;在 frontmatter 中输出 `coverImage`。
|
||||
|
||||
### 重构
|
||||
- `baoyu-danger-x-to-markdown`:frontmatter 字段改为 camelCase(`tweetCount`、`coverImage`、`requestedUrl` 等)。
|
||||
- `baoyu-format-markdown`:将主 frontmatter 字段从 `featureImage` 更名为 `coverImage`(兼容 `featureImage`)。
|
||||
- `baoyu-post-to-wechat`:封面图片 frontmatter 查找顺序中优先使用 `coverImage`。
|
||||
|
||||
## 1.31.2 - 2026-02-10
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-wechat`:修复 Windows 上 PowerShell 剪贴板复制失败的问题(`param()`/`-Path` 与 `-Command` 参数不兼容)。
|
||||
- `baoyu-post-to-x`:修复 Windows 上 PowerShell 剪贴板复制(同上);修复 `getScriptDir()` 在 Windows 上返回无效路径(`/C:/...` 前缀)。
|
||||
|
||||
## 1.31.1 - 2026-02-10
|
||||
|
||||
### 新功能
|
||||
- `baoyu-post-to-wechat`:适配微信新版 UI — 图文更名为贴图;新增 ProseMirror 编辑器支持(兼容旧版编辑器);新增备用文件上传选择器;新增上传进度监控;改进保存按钮检测并增加 toast 验证。
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-wechat`:摘要超过 120 字符时在标点处截断;修复封面图片相对路径解析。
|
||||
- `baoyu-post-to-x`:修复 macOS 上 Chrome 启动问题(使用 `open -na`);修复封面图片相对路径解析。
|
||||
|
||||
## 1.31.0 - 2026-02-07
|
||||
|
||||
### 新功能
|
||||
- `baoyu-post-to-wechat`:新增评论控制设置(`need_open_comment`、`only_fans_can_comment`);新增封面图片回退链(CLI → frontmatter → `imgs/cover.png` → 首张内联图片);新增作者优先级解析;新增首次使用引导流程和 EXTEND.md 偏好配置。
|
||||
|
||||
## 1.30.3 - 2026-02-06
|
||||
|
||||
### 重构
|
||||
- `baoyu-article-illustrator`:优化 SKILL.md 从 197 行精简至 150 行(减少 24%);采用渐进式披露模式,主文件提供简洁概览,详细内容通过引用文件提供。
|
||||
|
||||
## 1.30.2 - 2026-02-06
|
||||
|
||||
### 重构
|
||||
- `baoyu-cover-image`:优化 SKILL.md 从 532 行精简至 233 行(减少 56%);将参考图片处理流程提取到 `references/workflow/reference-images.md`;画廊改为纯值表格并链接到详细参考文件。
|
||||
|
||||
## 1.30.1 - 2026-02-06
|
||||
|
||||
### 新功能
|
||||
- `baoyu-image-gen`:新增 OpenAI GPT Image edits 支持参考图片(`--ref`);提供 ref 时自动选择 Google 或 OpenAI。
|
||||
|
||||
### 修复
|
||||
- `baoyu-image-gen`:将 ref 相关警告改为明确错误提示;新增参考图片验证。
|
||||
- `baoyu-cover-image`:增强参考图片分析,使用深度提取模板;要求 MUST INCORPORATE 章节以包含具体可复现的视觉元素。
|
||||
|
||||
## 1.30.0 - 2026-02-06
|
||||
|
||||
### 新功能
|
||||
- `baoyu-cover-image`:新增字体维度,支持 4 种字体风格(clean、handwritten、serif、display);包含自动选择规则、兼容性矩阵和 `warm-flat` 风格预设。
|
||||
|
||||
## 1.29.0 - 2026-02-06
|
||||
|
||||
### 新功能
|
||||
- `baoyu-image-gen`:新增 EXTEND.md 配置支持,补充配置 schema 文档并在脚本运行时读取偏好设置 (by @kingdomad)。
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-wechat`:修复公众号文章发布时标题和有序列表编号重复问题 (by @NantesCheval)。
|
||||
- `baoyu-url-to-markdown`:将正则转换升级为多策略正文抽取 + Turndown 转换,提升 Substack 类页面的噪声过滤能力。
|
||||
|
||||
## 1.28.4 - 2026-02-03
|
||||
|
||||
### 新功能
|
||||
- `baoyu-markdown-to-html`:从 YAML frontmatter 生成 author 和 description meta 标签;自动去除 frontmatter 值两端的引号(支持中英文引号)。
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-wechat`:移除图片粘贴后产生的多余空行;修复摘要填充时机,改为内容粘贴后填写(避免被覆盖)。
|
||||
|
||||
## 1.28.3 - 2026-02-03
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-wechat`:修复占位符匹配问题(`WECHATIMGPH_1` 错误匹配 `WECHATIMGPH_10`)。
|
||||
|
||||
## 1.28.2 - 2026-02-03
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-x`:复用已有 Chrome 实例;修复占位符匹配问题(`XIMGPH_1` 错误匹配 `XIMGPH_10`);改进图片按占位符序号排序;使用 `execCommand` 提高占位符删除可靠性。
|
||||
|
||||
## 1.28.1 - 2026-02-02
|
||||
|
||||
### 重构
|
||||
- `baoyu-article-illustrator`:简化主 SKILL.md,将详细步骤提取到 `workflow.md`;新增 Core Styles 快速选择层(vector、minimal-flat、sci-fi、hand-drawn、editorial、scene);新增 `vector-illustration` 作为推荐默认风格;新增插图目的(information/visualization/imagination)以优化类型/风格推荐;在提示词构建中新增默认构图要求、人物渲染指南和文本样式规则。
|
||||
|
||||
## 1.28.0 - 2026-02-01
|
||||
|
||||
### 新功能
|
||||
- `baoyu-cover-image`:新增参考图片支持(`--ref` 参数),支持 direct/style/palette 三种用法;新增视觉元素库,按主题分类图标词汇。
|
||||
- `baoyu-article-illustrator`:新增参考图片支持,支持 direct/style/palette 三种用法。
|
||||
- `baoyu-post-to-wechat`:新增 `newspic` 图文消息类型支持。
|
||||
|
||||
### 重构
|
||||
- `baoyu-cover-image`、`baoyu-article-illustrator`、`baoyu-comic`、`baoyu-xhs-images`:强化首次设置为阻塞操作,必须在其他工作流步骤之前完成。
|
||||
- `baoyu-cover-image`:移除标题字符数限制,使用原始来源标题。
|
||||
|
||||
## 1.26.1 - 2026-01-29
|
||||
|
||||
### 新功能
|
||||
- `baoyu-article-illustrator`、`baoyu-comic`、`baoyu-cover-image`、`baoyu-infographic`、`baoyu-slide-deck`、`baoyu-xhs-images`:新增文件备份规则,覆盖前自动将现有源文件、提示词和图片重命名为带时间戳后缀的备份文件。
|
||||
|
||||
### 修复
|
||||
- `baoyu-xhs-images`:移除 `notebook` 风格(保留 10 种风格)。
|
||||
|
||||
## 1.26.0 - 2026-01-29
|
||||
|
||||
### 新功能
|
||||
- `baoyu-xhs-images`:新增 `notebook` 风格(水彩渲染手绘信息图 + 莫兰迪配色)和 `study-notes` 风格(真实手写照片美学)。
|
||||
- `baoyu-xhs-images`:新增 `mindmap`(中心发散式)和 `quadrant`(四象限)布局。
|
||||
|
||||
## 1.25.4 - 2026-01-29
|
||||
|
||||
### 修复
|
||||
- `baoyu-markdown-to-html`:生成带 `data-local-path` 属性的 `<img>` 标签,而非纯文本占位符。
|
||||
- `baoyu-post-to-wechat`:修复 API 发布时从 `data-local-path` 属性读取图片路径;修复发布 HTML 文件时从对应 `.md` 的 frontmatter 提取标题和封面图。
|
||||
- `baoyu-post-to-wechat`:修复命令行参数解析,正确跳过未知参数;新增 `--summary` 参数支持。
|
||||
- `baoyu-post-to-wechat`:修复浏览器发布模式,粘贴前将 `<img>` 标签转换回文本占位符。
|
||||
|
||||
## 1.25.3 - 2026-01-28
|
||||
|
||||
### 新功能
|
||||
- `baoyu-format-markdown`:新增内容类型检测,对已有 markdown 格式的文件提供用户确认选项;新增 CJK 配对标点处理,将括号、引号等标点移出加粗标记外。
|
||||
|
||||
## 1.25.2 - 2026-01-28
|
||||
|
||||
### 文档
|
||||
- `baoyu-post-to-wechat`:README 新增微信公众号 API 凭证配置说明。
|
||||
|
||||
## 1.25.1 - 2026-01-28
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -519,12 +519,12 @@ Post content and articles to X (Twitter). Supports regular posts with images and
|
||||
|
||||
Post content to WeChat Official Account (微信公众号). Two modes available:
|
||||
|
||||
**Image-Text (图文)** - Multiple images with short title/content:
|
||||
**Image-Text (贴图)** - Multiple images with short title/content:
|
||||
|
||||
```bash
|
||||
/baoyu-post-to-wechat 图文 --markdown article.md --images ./photos/
|
||||
/baoyu-post-to-wechat 图文 --markdown article.md --image img1.png --image img2.png --image img3.png
|
||||
/baoyu-post-to-wechat 图文 --title "标题" --content "内容" --image img1.png --submit
|
||||
/baoyu-post-to-wechat 贴图 --markdown article.md --images ./photos/
|
||||
/baoyu-post-to-wechat 贴图 --markdown article.md --image img1.png --image img2.png --image img3.png
|
||||
/baoyu-post-to-wechat 贴图 --title "标题" --content "内容" --image img1.png --submit
|
||||
```
|
||||
|
||||
**Article (文章)** - Full markdown/HTML with rich formatting:
|
||||
@@ -535,7 +535,28 @@ Post content to WeChat Official Account (微信公众号). Two modes available:
|
||||
/baoyu-post-to-wechat 文章 --html article.html
|
||||
```
|
||||
|
||||
Prerequisites: Google Chrome installed. First run requires QR code login (session preserved).
|
||||
**Publishing Methods**:
|
||||
|
||||
| Method | Speed | Requirements |
|
||||
|--------|-------|--------------|
|
||||
| API (Recommended) | Fast | API credentials |
|
||||
| Browser | Slow | Chrome, login session |
|
||||
|
||||
**API Configuration** (for faster publishing):
|
||||
|
||||
```bash
|
||||
# Add to .baoyu-skills/.env (project-level) or ~/.baoyu-skills/.env (user-level)
|
||||
WECHAT_APP_ID=your_app_id
|
||||
WECHAT_APP_SECRET=your_app_secret
|
||||
```
|
||||
|
||||
To obtain credentials:
|
||||
1. Visit https://developers.weixin.qq.com/platform/
|
||||
2. Go to: 我的业务 → 公众号 → 开发密钥
|
||||
3. Create development key and copy AppID/AppSecret
|
||||
4. Add your machine's IP to the whitelist
|
||||
|
||||
**Browser Method** (no API setup needed): Requires Google Chrome. First run opens browser for QR code login (session preserved).
|
||||
|
||||
### AI Generation Skills
|
||||
|
||||
@@ -660,6 +681,9 @@ Converts X (Twitter) content to markdown format. Supports tweet threads and X Ar
|
||||
|
||||
# JSON output
|
||||
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --json
|
||||
|
||||
# Download media (images/videos) to local files
|
||||
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --download-media
|
||||
```
|
||||
|
||||
**Supported URLs:**
|
||||
@@ -692,7 +716,7 @@ Format plain text or markdown files with proper frontmatter, titles, summaries,
|
||||
|
||||
**Workflow**:
|
||||
1. Read source file and analyze content structure
|
||||
2. Check/create YAML frontmatter (title, slug, summary, featureImage)
|
||||
2. Check/create YAML frontmatter (title, slug, summary, coverImage)
|
||||
3. Handle title: use existing, extract from H1, or generate candidates
|
||||
4. Apply formatting: headings, bold, lists, code blocks, quotes
|
||||
5. Save to `{filename}-formatted.md`
|
||||
@@ -704,7 +728,7 @@ Format plain text or markdown files with proper frontmatter, titles, summaries,
|
||||
| `title` | Use existing, extract H1, or generate candidates |
|
||||
| `slug` | Infer from file path or generate from title |
|
||||
| `summary` | Generate engaging summary (100-150 chars) |
|
||||
| `featureImage` | Check for `imgs/cover.png` in same directory |
|
||||
| `coverImage` | Check for `imgs/cover.png` in same directory |
|
||||
|
||||
**Formatting Rules**:
|
||||
| Element | Format |
|
||||
|
||||
+31
-7
@@ -519,12 +519,12 @@ npx skills add jimliu/baoyu-skills
|
||||
|
||||
发布内容到微信公众号,支持两种模式:
|
||||
|
||||
**图文模式** - 多图配短标题和正文:
|
||||
**贴图模式** - 多图配短标题和正文:
|
||||
|
||||
```bash
|
||||
/baoyu-post-to-wechat 图文 --markdown article.md --images ./photos/
|
||||
/baoyu-post-to-wechat 图文 --markdown article.md --image img1.png --image img2.png --image img3.png
|
||||
/baoyu-post-to-wechat 图文 --title "标题" --content "内容" --image img1.png --submit
|
||||
/baoyu-post-to-wechat 贴图 --markdown article.md --images ./photos/
|
||||
/baoyu-post-to-wechat 贴图 --markdown article.md --image img1.png --image img2.png --image img3.png
|
||||
/baoyu-post-to-wechat 贴图 --title "标题" --content "内容" --image img1.png --submit
|
||||
```
|
||||
|
||||
**文章模式** - 完整 markdown/HTML 富文本格式:
|
||||
@@ -535,7 +535,28 @@ npx skills add jimliu/baoyu-skills
|
||||
/baoyu-post-to-wechat 文章 --html article.html
|
||||
```
|
||||
|
||||
前置要求:已安装 Google Chrome,首次运行需扫码登录(登录状态会保存)
|
||||
**发布方式**:
|
||||
|
||||
| 方式 | 速度 | 要求 |
|
||||
|------|------|------|
|
||||
| API(推荐) | 快 | API 凭证 |
|
||||
| 浏览器 | 慢 | Chrome,登录会话 |
|
||||
|
||||
**API 配置**(更快的发布方式):
|
||||
|
||||
```bash
|
||||
# 添加到 .baoyu-skills/.env(项目级)或 ~/.baoyu-skills/.env(用户级)
|
||||
WECHAT_APP_ID=你的AppID
|
||||
WECHAT_APP_SECRET=你的AppSecret
|
||||
```
|
||||
|
||||
获取凭证方法:
|
||||
1. 访问 https://developers.weixin.qq.com/platform/
|
||||
2. 进入:我的业务 → 公众号 → 开发密钥
|
||||
3. 添加开发密钥,复制 AppID 和 AppSecret
|
||||
4. 将你操作的机器 IP 加入白名单
|
||||
|
||||
**浏览器方式**(无需 API 配置):需已安装 Google Chrome,首次运行需扫码登录(登录状态会保存)
|
||||
|
||||
### AI 生成技能 (AI Generation Skills)
|
||||
|
||||
@@ -660,6 +681,9 @@ AI 驱动的生成后端。
|
||||
|
||||
# JSON 输出
|
||||
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --json
|
||||
|
||||
# 下载媒体文件(图片/视频)到本地
|
||||
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --download-media
|
||||
```
|
||||
|
||||
**支持的 URL:**
|
||||
@@ -692,7 +716,7 @@ AI 驱动的生成后端。
|
||||
|
||||
**工作流程**:
|
||||
1. 读取源文件并分析内容结构
|
||||
2. 检查/创建 YAML frontmatter(title、slug、summary、featureImage)
|
||||
2. 检查/创建 YAML frontmatter(title、slug、summary、coverImage)
|
||||
3. 处理标题:使用现有标题、提取 H1 或生成候选标题
|
||||
4. 应用格式:层级标题、加粗、列表、代码块、引用
|
||||
5. 保存为 `{文件名}-formatted.md`
|
||||
@@ -704,7 +728,7 @@ AI 驱动的生成后端。
|
||||
| `title` | 使用现有、提取 H1 或生成候选 |
|
||||
| `slug` | 从文件路径推断或根据标题生成 |
|
||||
| `summary` | 生成吸引人的摘要(100-150 字) |
|
||||
| `featureImage` | 检查同目录下 `imgs/cover.png` |
|
||||
| `coverImage` | 检查同目录下 `imgs/cover.png` |
|
||||
|
||||
**格式化规则**:
|
||||
| 元素 | 格式 |
|
||||
|
||||
@@ -11,108 +11,40 @@ Analyze articles, identify illustration positions, generate images with Type ×
|
||||
|
||||
| Dimension | Controls | Examples |
|
||||
|-----------|----------|----------|
|
||||
| **Type** | Information structure, layout | infographic, scene, flowchart, comparison, framework, timeline |
|
||||
| **Style** | Visual aesthetics, mood | notion, warm, minimal, blueprint, watercolor, elegant |
|
||||
| **Type** | Information structure | infographic, scene, flowchart, comparison, framework, timeline |
|
||||
| **Style** | Visual aesthetics | notion, warm, minimal, blueprint, watercolor, elegant |
|
||||
|
||||
Type × Style can be freely combined. Example: `--type infographic --style blueprint`
|
||||
Combine freely: `--type infographic --style blueprint`
|
||||
|
||||
## Type Gallery
|
||||
## Types
|
||||
|
||||
| Type | Best For |
|
||||
|------|----------|
|
||||
| `infographic` | Data, metrics, technical articles |
|
||||
| `scene` | Narratives, personal stories, emotional content |
|
||||
| `flowchart` | Tutorials, workflows, processes |
|
||||
| `comparison` | Side-by-side, before/after, options |
|
||||
| `framework` | Methodologies, models, architecture |
|
||||
| `timeline` | History, progress, evolution |
|
||||
| `infographic` | Data, metrics, technical |
|
||||
| `scene` | Narratives, emotional |
|
||||
| `flowchart` | Processes, workflows |
|
||||
| `comparison` | Side-by-side, options |
|
||||
| `framework` | Models, architecture |
|
||||
| `timeline` | History, evolution |
|
||||
|
||||
## Style Gallery
|
||||
## Styles
|
||||
|
||||
| Style | Best For |
|
||||
|-------|----------|
|
||||
| `notion` (Default) | Knowledge sharing, SaaS, productivity |
|
||||
| `elegant` | Business, thought leadership |
|
||||
| `warm` | Personal growth, lifestyle, education |
|
||||
| `minimal` | Philosophy, core concepts |
|
||||
| `blueprint` | Architecture, system design |
|
||||
| `watercolor` | Lifestyle, travel, creative |
|
||||
| `editorial` | Tech explainers, journalism |
|
||||
| `scientific` | Academic, technical research |
|
||||
|
||||
Full styles: [references/styles.md](references/styles.md)
|
||||
|
||||
## Auto Selection
|
||||
|
||||
| Content Signals | Type | Style |
|
||||
|-----------------|------|-------|
|
||||
| API, metrics, data, numbers | infographic | blueprint, notion |
|
||||
| Story, emotion, journey | scene | warm, watercolor |
|
||||
| How-to, steps, workflow | flowchart | notion, minimal |
|
||||
| vs, pros/cons, before/after | comparison | notion, elegant |
|
||||
| Framework, model, architecture | framework | blueprint, notion |
|
||||
| History, timeline, progress | timeline | elegant, warm |
|
||||
See [references/styles.md](references/styles.md) for Core Styles, full gallery, and Type × Style compatibility.
|
||||
|
||||
## Workflow
|
||||
|
||||
Copy this checklist and track progress:
|
||||
|
||||
```
|
||||
Progress:
|
||||
- [ ] Step 1: Pre-check
|
||||
- [ ] Step 2: Setup & Analyze
|
||||
- [ ] Step 3: Confirm Settings ⚠️ REQUIRED
|
||||
- [ ] Step 4: Generate Outline
|
||||
- [ ] Step 5: Generate Images
|
||||
- [ ] Step 1: Pre-check (EXTEND.md, references, config)
|
||||
- [ ] Step 2: Analyze content
|
||||
- [ ] Step 3: Confirm settings (AskUserQuestion)
|
||||
- [ ] Step 4: Generate outline
|
||||
- [ ] Step 5: Generate images
|
||||
- [ ] Step 6: Finalize
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 1: Pre-check
|
||||
|
||||
**1.1 Determine Input Type**
|
||||
|
||||
| Input | Output Directory | Next |
|
||||
|-------|------------------|------|
|
||||
| File path | Ask user (1.2) | → 1.2 |
|
||||
| Pasted content | `illustrations/{topic-slug}/` | → 1.4 |
|
||||
|
||||
**1.2 Determine Output Directory** (file path input only)
|
||||
|
||||
Check `default_output_dir` in preferences:
|
||||
|
||||
| Preference Value | Action |
|
||||
|------------------|--------|
|
||||
| `same-dir` | Use `{article-dir}/`, display "Output: {path}" |
|
||||
| `imgs-subdir` | Use `{article-dir}/imgs/`, display "Output: {path}" |
|
||||
| `illustrations-subdir` | Use `{article-dir}/illustrations/`, display "Output: {path}" |
|
||||
| `independent` | Use `illustrations/{topic-slug}/`, display "Output: {path}" |
|
||||
| Not configured | **MUST** ask with AskUserQuestion ↓ |
|
||||
|
||||
**AskUserQuestion** (when no preference):
|
||||
- `{article-dir}/` - Same directory as article
|
||||
- `{article-dir}/imgs/` - Images subdirectory
|
||||
- `{article-dir}/illustrations/` - Illustrations subdirectory (Recommended)
|
||||
- `illustrations/{topic-slug}/` - Independent directory
|
||||
- Save as default - Remember this choice for future runs
|
||||
|
||||
**1.3 Check Existing Images**
|
||||
|
||||
Scan target directory for `.png/.jpg/.webp` files.
|
||||
|
||||
If images exist → AskUserQuestion: How to handle?
|
||||
- `supplement` - Keep existing, generate only new positions
|
||||
- `overwrite` - Overwrite same-name files
|
||||
- `regenerate` - Clear all and regenerate
|
||||
|
||||
**1.4 Confirm Article Update Method** (file path input only)
|
||||
|
||||
AskUserQuestion: How to update article?
|
||||
- `update` - Modify original file directly
|
||||
- `copy` - Create `{name}-illustrated.md` copy
|
||||
|
||||
**1.5 Load Preferences (EXTEND.md)**
|
||||
**1.5 Load Preferences (EXTEND.md) ⛔ BLOCKING**
|
||||
|
||||
```bash
|
||||
test -f .baoyu-skills/baoyu-article-illustrator/EXTEND.md && echo "project"
|
||||
@@ -122,207 +54,102 @@ test -f "$HOME/.baoyu-skills/baoyu-article-illustrator/EXTEND.md" && echo "user"
|
||||
| Result | Action |
|
||||
|--------|--------|
|
||||
| Found | Read, parse, display summary |
|
||||
| Not found | Ask with AskUserQuestion (see 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
|
||||
Full procedures: [references/workflow.md](references/workflow.md#step-1-pre-check)
|
||||
|
||||
---
|
||||
### Step 2: Analyze
|
||||
|
||||
### Step 2: Setup & Analyze
|
||||
|
||||
**2.1 Analyze Content**
|
||||
|
||||
| Analysis | Description |
|
||||
|----------|-------------|
|
||||
| Analysis | Output |
|
||||
|----------|--------|
|
||||
| Content type | Technical / Tutorial / Methodology / Narrative |
|
||||
| Core arguments | 2-5 main points to visualize |
|
||||
| Visual opportunities | Positions where illustrations add value |
|
||||
| Recommended type | Based on content signals |
|
||||
| Recommended density | Based on length and complexity |
|
||||
| Purpose | information / visualization / imagination |
|
||||
| Core arguments | 2-5 main points |
|
||||
| Positions | Where illustrations add value |
|
||||
|
||||
**2.2 Extract Core Arguments**
|
||||
**CRITICAL**: Metaphors → visualize underlying concept, NOT literal image.
|
||||
|
||||
- Main thesis
|
||||
- Key concepts reader needs
|
||||
- Comparisons/contrasts
|
||||
- Framework/model proposed
|
||||
|
||||
**CRITICAL**: If article uses metaphors (e.g., "电锯切西瓜"), do NOT illustrate literally. Visualize the **underlying concept**.
|
||||
|
||||
**2.3 Identify Positions**
|
||||
|
||||
**Illustrate**:
|
||||
- Core arguments (REQUIRED)
|
||||
- Abstract concepts
|
||||
- Data comparisons
|
||||
- Processes, workflows
|
||||
|
||||
**Do NOT Illustrate**:
|
||||
- Metaphors literally
|
||||
- Decorative scenes
|
||||
- Generic illustrations
|
||||
|
||||
---
|
||||
Full procedures: [references/workflow.md](references/workflow.md#step-2-setup--analyze)
|
||||
|
||||
### Step 3: Confirm Settings ⚠️
|
||||
|
||||
**Do NOT skip.** Use AskUserQuestion with 3-4 questions in ONE call.
|
||||
**ONE AskUserQuestion, max 4 Qs. Q1-Q3 REQUIRED.**
|
||||
|
||||
**Q1: Illustration Type**
|
||||
- [Recommended based on analysis] (Recommended)
|
||||
- infographic / scene / flowchart / comparison / framework / timeline / mixed
|
||||
| Q | Options |
|
||||
|---|---------|
|
||||
| **Q1: Type** | [Recommended], infographic, scene, flowchart, comparison, framework, timeline, mixed |
|
||||
| **Q2: Density** | minimal (1-2), balanced (3-5), per-section (Recommended), rich (6+) |
|
||||
| **Q3: Style** | [Recommended], minimal-flat, sci-fi, hand-drawn, editorial, scene, Other |
|
||||
| Q4: Language | When article language ≠ EXTEND.md setting |
|
||||
|
||||
**Q2: Density**
|
||||
- minimal (1-2) - Core concepts only
|
||||
- balanced (3-5) (Recommended) - Major sections
|
||||
- rich (6+) - Comprehensive support
|
||||
|
||||
**Q3: Style** (ALWAYS ask, even with preferred_style in EXTEND.md)
|
||||
|
||||
If EXTEND.md has `preferred_style`:
|
||||
- [Custom style name + brief description] (Recommended)
|
||||
- [Top compatible built-in style 1]
|
||||
- [Top compatible built-in style 2]
|
||||
- [Top compatible built-in style 3]
|
||||
|
||||
If no `preferred_style`:
|
||||
- [Best compatible from matrix] (Recommended)
|
||||
- [Other ✓✓ style 1]
|
||||
- [Other ✓✓ style 2]
|
||||
- [Other ✓ style]
|
||||
|
||||
Style selection based on Type × Style compatibility matrix (references/styles.md).
|
||||
Full specs: `references/styles/<style>.md`
|
||||
|
||||
**Q4** (only if source ≠ user language):
|
||||
- Language: Source / User language
|
||||
|
||||
---
|
||||
Full procedures: [references/workflow.md](references/workflow.md#step-3-confirm-settings-)
|
||||
|
||||
### Step 4: Generate Outline
|
||||
|
||||
Save as `outline.md`:
|
||||
Save `outline.md` with frontmatter (type, density, style, image_count) and entries:
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: infographic
|
||||
density: balanced
|
||||
style: blueprint
|
||||
image_count: 4
|
||||
---
|
||||
|
||||
## Illustration 1
|
||||
|
||||
**Position**: [section] / [paragraph]
|
||||
**Purpose**: [why this helps]
|
||||
**Visual Content**: [what to show]
|
||||
**Type Application**: [how type applies]
|
||||
**Position**: [section/paragraph]
|
||||
**Purpose**: [why]
|
||||
**Visual Content**: [what]
|
||||
**Filename**: 01-infographic-concept-name.png
|
||||
|
||||
## Illustration 2
|
||||
...
|
||||
```
|
||||
|
||||
**Requirements**:
|
||||
- Each position justified by content needs
|
||||
- Type applied consistently
|
||||
- Style reflected in descriptions
|
||||
- Count matches density
|
||||
|
||||
---
|
||||
Full template: [references/workflow.md](references/workflow.md#step-4-generate-outline)
|
||||
|
||||
### Step 5: Generate Images
|
||||
|
||||
**5.1 Create Prompts**
|
||||
⛔ **BLOCKING: Prompt files MUST be saved before ANY image generation.**
|
||||
|
||||
Follow [references/prompt-construction.md](references/prompt-construction.md). Save to `prompts/illustration-{slug}.md`.
|
||||
1. For each illustration, create a prompt file per [references/prompt-construction.md](references/prompt-construction.md)
|
||||
2. Save to `prompts/NN-{type}-{slug}.md` with YAML frontmatter
|
||||
3. Prompts **MUST** use type-specific templates with structured sections (ZONES / LABELS / COLORS / STYLE / ASPECT)
|
||||
4. LABELS **MUST** include article-specific data: actual numbers, terms, metrics, quotes
|
||||
5. **DO NOT** pass ad-hoc inline prompts to `--prompt` without saving prompt files first
|
||||
6. Select generation skill, process references (`direct`/`style`/`palette`)
|
||||
7. Apply watermark if EXTEND.md enabled
|
||||
8. Generate from saved prompt files; retry once on failure
|
||||
|
||||
**5.2 Select Generation Skill**
|
||||
|
||||
Check available skills. If multiple, ask user.
|
||||
|
||||
**5.3 Apply Watermark** (if enabled)
|
||||
|
||||
Add: `Include a subtle watermark "[content]" at [position].`
|
||||
|
||||
**5.4 Generate**
|
||||
|
||||
1. Generate sequentially
|
||||
2. After each: "Generated X/N"
|
||||
3. On failure: retry once, then log and continue
|
||||
|
||||
---
|
||||
Full procedures: [references/workflow.md](references/workflow.md#step-5-generate-images)
|
||||
|
||||
### Step 6: Finalize
|
||||
|
||||
**6.1 Update Article**
|
||||
|
||||
Insert after corresponding paragraph:
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
Alt text: concise description in article's language.
|
||||
|
||||
**6.2 Output Summary**
|
||||
Insert `` after paragraphs.
|
||||
|
||||
```
|
||||
Article Illustration Complete!
|
||||
|
||||
Article: [path]
|
||||
Type: [type] | Density: [level] | Style: [style]
|
||||
Location: [directory]
|
||||
Article: [path] | Type: [type] | Density: [level] | Style: [style]
|
||||
Images: X/N generated
|
||||
|
||||
Positions:
|
||||
- 01-xxx.png → After "[Section]"
|
||||
- 02-yyy.png → After "[Section]"
|
||||
|
||||
[If failures]
|
||||
Failed:
|
||||
- NN-zzz.png: [reason]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Output Directory
|
||||
|
||||
```
|
||||
illustrations/{topic-slug}/
|
||||
├── source-{slug}.{ext}
|
||||
├── references/ # if provided
|
||||
├── outline.md
|
||||
├── prompts/
|
||||
│ └── illustration-{slug}.md
|
||||
└── NN-{type}-{slug}.png
|
||||
```
|
||||
|
||||
**Slug**: 2-4 word topic in kebab-case.
|
||||
**Conflict**: Append `-YYYYMMDD-HHMMSS` if exists.
|
||||
**Slug**: 2-4 words, kebab-case. **Conflict**: append `-YYYYMMDD-HHMMSS`.
|
||||
|
||||
## Modification
|
||||
|
||||
| Action | Steps |
|
||||
|--------|-------|
|
||||
| **Edit** | **Update prompt file FIRST** → Regenerate → Update reference |
|
||||
| **Add** | Identify position → Create prompt → Generate → Update outline → Insert |
|
||||
| **Delete** | Delete files → Remove reference → Update outline |
|
||||
|
||||
**IMPORTANT**: When updating illustrations, ALWAYS update the prompt file (`prompts/illustration-{slug}.md`) FIRST before regenerating. This ensures:
|
||||
1. Changes are documented and reproducible
|
||||
2. The prompt reflects the new requirements
|
||||
3. Future regeneration uses the correct prompt
|
||||
| Edit | Update prompt → Regenerate → Update reference |
|
||||
| Add | Position → Prompt → Generate → Update outline → Insert |
|
||||
| Delete | Delete files → Remove reference → Update outline |
|
||||
|
||||
## References
|
||||
|
||||
| File | Content |
|
||||
|------|---------|
|
||||
| [references/usage.md](references/usage.md) | Command syntax and options |
|
||||
| [references/styles.md](references/styles.md) | Style gallery & compatibility |
|
||||
| [references/workflow.md](references/workflow.md) | Detailed procedures |
|
||||
| [references/usage.md](references/usage.md) | Command syntax |
|
||||
| [references/styles.md](references/styles.md) | Style gallery |
|
||||
| [references/prompt-construction.md](references/prompt-construction.md) | Prompt templates |
|
||||
| `references/styles/<style>.md` | Full style specifications |
|
||||
| `references/config/preferences-schema.md` | EXTEND.md schema |
|
||||
| `references/config/first-time-setup.md` | First-time setup flow |
|
||||
|
||||
## Extension Support
|
||||
|
||||
Custom configurations via EXTEND.md. See **Step 1.5** for paths and supported options.
|
||||
| [references/config/first-time-setup.md](references/config/first-time-setup.md) | First-time setup |
|
||||
|
||||
@@ -9,6 +9,14 @@ description: First-time setup flow for baoyu-article-illustrator preferences
|
||||
|
||||
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 reference images
|
||||
- Ask about content/article
|
||||
- Ask about type or style preferences
|
||||
- Proceed to content analysis
|
||||
|
||||
ONLY ask the questions in this setup flow, save EXTEND.md, then continue.
|
||||
|
||||
## Setup Flow
|
||||
|
||||
```
|
||||
|
||||
@@ -1,5 +1,102 @@
|
||||
# Prompt Construction
|
||||
|
||||
## Prompt File Format
|
||||
|
||||
Each prompt file uses YAML frontmatter + content:
|
||||
|
||||
```yaml
|
||||
---
|
||||
illustration_id: 01
|
||||
type: infographic
|
||||
style: blueprint
|
||||
references: # ⚠️ ONLY if files EXIST in references/ directory
|
||||
- ref_id: 01
|
||||
filename: 01-ref-diagram.png
|
||||
usage: direct # direct | style | palette
|
||||
---
|
||||
|
||||
[Type-specific template content below...]
|
||||
```
|
||||
|
||||
**⚠️ CRITICAL - When to include `references` field**:
|
||||
|
||||
| Situation | Action |
|
||||
|-----------|--------|
|
||||
| Reference file saved to `references/` | Include in frontmatter ✓ |
|
||||
| Style extracted verbally (no file) | DO NOT include in frontmatter, append to prompt body instead |
|
||||
| File path in frontmatter but file doesn't exist | ERROR - remove references field |
|
||||
|
||||
**Reference Usage Types** (only when file exists):
|
||||
|
||||
| Usage | Description | Generation Action |
|
||||
|-------|-------------|-------------------|
|
||||
| `direct` | Primary visual reference | Pass to `--ref` parameter |
|
||||
| `style` | Style characteristics only | Describe style in prompt text |
|
||||
| `palette` | Color palette extraction | Include colors in prompt |
|
||||
|
||||
**If no reference file but style/palette extracted verbally**, append directly to prompt body:
|
||||
```
|
||||
COLORS (from reference):
|
||||
- Primary: #E8756D coral
|
||||
- Secondary: #7ECFC0 mint
|
||||
...
|
||||
|
||||
STYLE (from reference):
|
||||
- Clean lines, minimal shadows
|
||||
- Gradient backgrounds
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Default Composition Requirements
|
||||
|
||||
**Apply to ALL prompts by default**:
|
||||
|
||||
| Requirement | Description |
|
||||
|-------------|-------------|
|
||||
| **Clean composition** | Simple layouts, no visual clutter |
|
||||
| **White space** | Generous margins, breathing room around elements |
|
||||
| **No complex backgrounds** | Solid colors or subtle gradients only, avoid busy textures |
|
||||
| **Centered or content-appropriate** | Main visual elements centered or positioned by content needs |
|
||||
| **Matching graphics** | Use graphic elements that align with content theme |
|
||||
| **Highlight core info** | White space draws attention to key information |
|
||||
|
||||
**Add to ALL prompts**:
|
||||
> Clean composition with generous white space. Simple or no background. Main elements centered or positioned by content needs.
|
||||
|
||||
---
|
||||
|
||||
## Character Rendering
|
||||
|
||||
When depicting people:
|
||||
|
||||
| Guideline | Description |
|
||||
|-----------|-------------|
|
||||
| **Style** | Simplified cartoon silhouettes or symbolic expressions |
|
||||
| **Avoid** | Realistic human portrayals, detailed faces |
|
||||
| **Diversity** | Varied body types when showing multiple people |
|
||||
| **Emotion** | Express through posture and simple gestures |
|
||||
|
||||
**Add to ALL prompts with human figures**:
|
||||
> Human figures: simplified stylized silhouettes or symbolic representations, not photorealistic.
|
||||
|
||||
---
|
||||
|
||||
## Text in Illustrations
|
||||
|
||||
| Element | Guideline |
|
||||
|---------|-----------|
|
||||
| **Size** | Large, prominent, immediately readable |
|
||||
| **Style** | Handwritten fonts preferred for warmth |
|
||||
| **Content** | Concise keywords and core concepts only |
|
||||
| **Language** | Match article language |
|
||||
|
||||
**Add to prompts with text**:
|
||||
> Text should be large and prominent with handwritten-style fonts. Keep minimal, focus on keywords.
|
||||
|
||||
---
|
||||
|
||||
## Principles
|
||||
|
||||
Good prompts must include:
|
||||
@@ -31,6 +128,13 @@ STYLE: [style characteristics]
|
||||
ASPECT: 16:9
|
||||
```
|
||||
|
||||
**Infographic + vector-illustration**:
|
||||
```
|
||||
Flat vector illustration infographic. Clean black outlines on all elements.
|
||||
COLORS: Cream background (#F5F0E6), Coral Red (#E07A5F), Mint Green (#81B29A), Mustard Yellow (#F2CC8F)
|
||||
ELEMENTS: Geometric simplified icons, no gradients, playful decorative elements (dots, stars)
|
||||
```
|
||||
|
||||
### Scene
|
||||
|
||||
```
|
||||
@@ -61,6 +165,13 @@ STYLE: [style characteristics]
|
||||
ASPECT: 16:9
|
||||
```
|
||||
|
||||
**Flowchart + vector-illustration**:
|
||||
```
|
||||
Flat vector flowchart with bold arrows and geometric step containers.
|
||||
COLORS: Cream background (#F5F0E6), steps in Coral/Mint/Mustard, black outlines
|
||||
ELEMENTS: Rounded rectangles, thick arrows, simple icons per step
|
||||
```
|
||||
|
||||
### Comparison
|
||||
|
||||
```
|
||||
@@ -79,6 +190,13 @@ STYLE: [style characteristics]
|
||||
ASPECT: 16:9
|
||||
```
|
||||
|
||||
**Comparison + vector-illustration**:
|
||||
```
|
||||
Flat vector comparison with split layout. Clear visual separation.
|
||||
COLORS: Left side Coral (#E07A5F), Right side Mint (#81B29A), cream background
|
||||
ELEMENTS: Bold icons, black outlines, centered divider line
|
||||
```
|
||||
|
||||
### Framework
|
||||
|
||||
```
|
||||
@@ -95,6 +213,13 @@ STYLE: [style characteristics]
|
||||
ASPECT: 16:9
|
||||
```
|
||||
|
||||
**Framework + vector-illustration**:
|
||||
```
|
||||
Flat vector framework diagram with geometric nodes and bold connectors.
|
||||
COLORS: Cream background (#F5F0E6), nodes in Coral/Mint/Mustard/Blue, black outlines
|
||||
ELEMENTS: Rounded rectangles or circles for nodes, thick connecting lines
|
||||
```
|
||||
|
||||
### Timeline
|
||||
|
||||
```
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
# Style Reference
|
||||
|
||||
## Core Styles
|
||||
|
||||
Simplified style tier for quick selection:
|
||||
|
||||
| Core Style | Maps To | Best For |
|
||||
|------------|---------|----------|
|
||||
| `vector` | vector-illustration | Knowledge articles, tutorials, tech content |
|
||||
| `minimal-flat` | notion | General, knowledge sharing, SaaS |
|
||||
| `sci-fi` | blueprint | AI, frontier tech, system design |
|
||||
| `hand-drawn` | sketch/warm | Relaxed, reflective, casual content |
|
||||
| `editorial` | editorial | Processes, data, journalism |
|
||||
| `scene` | warm/watercolor | Narratives, emotional, lifestyle |
|
||||
|
||||
Use Core Styles for most cases. See full Style Gallery below for granular control.
|
||||
|
||||
---
|
||||
|
||||
## Style Gallery
|
||||
|
||||
| Style | Description | Best For |
|
||||
|-------|-------------|----------|
|
||||
| `notion` (Default) | Minimalist hand-drawn line art | Knowledge sharing, SaaS, productivity |
|
||||
| `vector-illustration` | Clean flat vector art with bold shapes | Knowledge articles, tutorials, tech content |
|
||||
| `notion` | Minimalist hand-drawn line art | Knowledge sharing, SaaS, productivity |
|
||||
| `elegant` | Refined, sophisticated | Business, thought leadership |
|
||||
| `warm` | Friendly, approachable | Personal growth, lifestyle, education |
|
||||
| `minimal` | Ultra-clean, zen-like | Philosophy, minimalism, core concepts |
|
||||
@@ -12,19 +30,31 @@
|
||||
| `watercolor` | Soft artistic with natural warmth | Lifestyle, travel, creative |
|
||||
| `editorial` | Magazine-style infographic | Tech explainers, journalism |
|
||||
| `scientific` | Academic precise diagrams | Biology, chemistry, technical research |
|
||||
| `chalkboard` | Classroom chalk drawing style | Education, teaching, explanations |
|
||||
| `fantasy-animation` | Ghibli/Disney-inspired hand-drawn | Storybook, magical, emotional |
|
||||
| `flat` | Modern bold geometric shapes | Modern digital, contemporary |
|
||||
| `flat-doodle` | Cute flat with bold outlines | Cute, friendly, approachable |
|
||||
| `intuition-machine` | Technical briefing with aged paper | Technical briefings, academic |
|
||||
| `nature` | Organic earthy illustration | Environmental, wellness |
|
||||
| `pixel-art` | Retro 8-bit gaming aesthetic | Gaming, retro tech |
|
||||
| `playful` | Whimsical pastel doodles | Fun, casual, educational |
|
||||
| `retro` | 80s/90s neon geometric | 80s/90s nostalgic, bold |
|
||||
| `sketch` | Raw pencil notebook style | Brainstorming, creative exploration |
|
||||
| `sketch-notes` | Soft hand-drawn warm notes | Educational, warm notes |
|
||||
| `vintage` | Aged parchment historical | Historical, heritage |
|
||||
|
||||
Full specifications: `references/styles/<style>.md`
|
||||
|
||||
## Type × Style Compatibility Matrix
|
||||
|
||||
| | notion | warm | minimal | blueprint | watercolor | elegant | editorial | scientific |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| infographic | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓✓ |
|
||||
| scene | ✓ | ✓✓ | ✓ | ✗ | ✓✓ | ✓ | ✓ | ✗ |
|
||||
| flowchart | ✓✓ | ✓ | ✓ | ✓✓ | ✗ | ✓ | ✓✓ | ✓ |
|
||||
| comparison | ✓✓ | ✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
| framework | ✓✓ | ✓ | ✓✓ | ✓✓ | ✗ | ✓✓ | ✓ | ✓✓ |
|
||||
| timeline | ✓✓ | ✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓✓ | ✓ |
|
||||
| | vector-illustration | notion | warm | minimal | blueprint | watercolor | elegant | editorial | scientific |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| infographic | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓✓ |
|
||||
| scene | ✓ | ✓ | ✓✓ | ✓ | ✗ | ✓✓ | ✓ | ✓ | ✗ |
|
||||
| flowchart | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✗ | ✓ | ✓✓ | ✓ |
|
||||
| comparison | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
| framework | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✗ | ✓✓ | ✓ | ✓✓ |
|
||||
| timeline | ✓ | ✓✓ | ✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓✓ | ✓ |
|
||||
|
||||
✓✓ = highly recommended | ✓ = compatible | ✗ = not recommended
|
||||
|
||||
@@ -32,30 +62,57 @@ Full specifications: `references/styles/<style>.md`
|
||||
|
||||
| Type | Primary Style | Secondary Styles |
|
||||
|------|---------------|------------------|
|
||||
| infographic | blueprint | notion, editorial, scientific |
|
||||
| infographic | vector-illustration | notion, blueprint, editorial |
|
||||
| scene | warm | watercolor, elegant |
|
||||
| flowchart | notion | blueprint, editorial |
|
||||
| comparison | notion | elegant, editorial |
|
||||
| framework | blueprint | notion, scientific |
|
||||
| flowchart | vector-illustration | notion, blueprint |
|
||||
| comparison | vector-illustration | notion, elegant |
|
||||
| framework | blueprint | vector-illustration, notion |
|
||||
| timeline | elegant | warm, editorial |
|
||||
|
||||
## Auto Selection by Content Signals
|
||||
|
||||
| Content Signals | Recommended Type | Recommended Style |
|
||||
|-----------------|------------------|-------------------|
|
||||
| API, metrics, data, comparison, numbers | infographic | blueprint, notion |
|
||||
| API, metrics, data, comparison, numbers | infographic | blueprint, vector-illustration |
|
||||
| Knowledge, concept, tutorial, learning, guide | infographic | vector-illustration, notion |
|
||||
| Tech, AI, programming, development, code | infographic | vector-illustration, blueprint |
|
||||
| How-to, steps, workflow, process, tutorial | flowchart | vector-illustration, notion |
|
||||
| Framework, model, architecture, principles | framework | blueprint, vector-illustration |
|
||||
| vs, pros/cons, before/after, alternatives | comparison | vector-illustration, notion |
|
||||
| Story, emotion, journey, experience, personal | scene | warm, watercolor |
|
||||
| How-to, steps, workflow, process, tutorial | flowchart | notion, minimal |
|
||||
| vs, pros/cons, before/after, alternatives | comparison | notion, elegant |
|
||||
| Framework, model, architecture, principles | framework | blueprint, notion |
|
||||
| History, timeline, progress, evolution | timeline | elegant, warm |
|
||||
| Knowledge, concept, productivity, SaaS, tool | infographic | notion |
|
||||
| Productivity, SaaS, tool, app, software | infographic | notion, vector-illustration |
|
||||
| Business, professional, strategy, corporate | framework | elegant |
|
||||
| Biology, chemistry, medical, scientific | infographic | scientific |
|
||||
| Explainer, journalism, magazine, investigation | infographic | editorial |
|
||||
|
||||
## Style Characteristics by Type
|
||||
|
||||
### infographic + vector-illustration
|
||||
- Clean flat vector shapes, bold geometric forms
|
||||
- Vibrant but harmonious color palette
|
||||
- Clear visual hierarchy with icons and labels
|
||||
- Modern, professional, highly readable
|
||||
- Perfect for knowledge articles and tutorials
|
||||
|
||||
### flowchart + vector-illustration
|
||||
- Bold arrows and connectors
|
||||
- Distinct step containers with icons
|
||||
- Clean progression flow
|
||||
- High contrast for readability
|
||||
|
||||
### comparison + vector-illustration
|
||||
- Split layout with clear visual separation
|
||||
- Bold iconography for each side
|
||||
- Color-coded distinctions
|
||||
- Easy at-a-glance comparison
|
||||
|
||||
### framework + vector-illustration
|
||||
- Geometric node representations
|
||||
- Clear hierarchical structure
|
||||
- Bold connecting lines
|
||||
- Modern system diagram aesthetic
|
||||
|
||||
### infographic + blueprint
|
||||
- Technical precision, schematic lines
|
||||
- Grid-based layout, clear zones
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
# Detailed Workflow Procedures
|
||||
|
||||
## Step 1: Pre-check
|
||||
|
||||
### 1.0 Detect & Save Reference Images ⚠️ REQUIRED if images provided
|
||||
|
||||
Check if user provided reference images. Handle based on input type:
|
||||
|
||||
| Input Type | Action |
|
||||
|------------|--------|
|
||||
| Image file path provided | Copy to `references/` subdirectory → can use `--ref` |
|
||||
| Image in conversation (no path) | **ASK user for file path** with AskUserQuestion |
|
||||
| User can't provide path | Extract style/palette verbally → append to prompts (NO frontmatter references) |
|
||||
|
||||
**CRITICAL**: Only add `references` to prompt frontmatter if files are ACTUALLY SAVED to `references/` directory.
|
||||
|
||||
**If user provides file path**:
|
||||
1. Copy to `references/NN-ref-{slug}.png`
|
||||
2. Create description: `references/NN-ref-{slug}.md`
|
||||
3. Verify files exist before proceeding
|
||||
|
||||
**If user can't provide path** (extracted verbally):
|
||||
1. Analyze image visually, extract: colors, style, composition
|
||||
2. Create `references/extracted-style.md` with extracted info
|
||||
3. DO NOT add `references` to prompt frontmatter
|
||||
4. Instead, append extracted style/colors directly to prompt text
|
||||
|
||||
**Description File Format** (only when file saved):
|
||||
```yaml
|
||||
---
|
||||
ref_id: NN
|
||||
filename: NN-ref-{slug}.png
|
||||
---
|
||||
[User's description or auto-generated description]
|
||||
```
|
||||
|
||||
**Verification** (only for saved files):
|
||||
```
|
||||
Reference Images Saved:
|
||||
- 01-ref-{slug}.png ✓ (can use --ref)
|
||||
- 02-ref-{slug}.png ✓ (can use --ref)
|
||||
```
|
||||
|
||||
**Or for extracted style**:
|
||||
```
|
||||
Reference Style Extracted (no file):
|
||||
- Colors: #E8756D coral, #7ECFC0 mint...
|
||||
- Style: minimal flat vector, clean lines...
|
||||
→ Will append to prompt text (not --ref)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.1 Determine Input Type
|
||||
|
||||
| Input | Output Directory | Next |
|
||||
|-------|------------------|------|
|
||||
| File path | Ask user (1.2) | → 1.2 |
|
||||
| Pasted content | `illustrations/{topic-slug}/` | → 1.4 |
|
||||
|
||||
**Backup rule for pasted content**: If `source.md` exists in target directory, rename to `source-backup-YYYYMMDD-HHMMSS.md` before saving.
|
||||
|
||||
### 1.2-1.4 Configuration (file path input only)
|
||||
|
||||
Check preferences and existing state, then ask ALL needed questions in ONE AskUserQuestion call (max 4 questions).
|
||||
|
||||
**Questions to include** (skip if preference exists or not applicable):
|
||||
|
||||
| Question | When to Ask | Options |
|
||||
|----------|-------------|---------|
|
||||
| Output directory | No `default_output_dir` in EXTEND.md | `{article-dir}/`, `{article-dir}/imgs/` (Recommended), `{article-dir}/illustrations/`, `illustrations/{topic-slug}/` |
|
||||
| Existing images | Target dir has `.png/.jpg/.webp` files | `supplement`, `overwrite`, `regenerate` |
|
||||
| Article update | Always (file path input) | `update`, `copy` |
|
||||
|
||||
**Preference Values** (if configured, skip asking):
|
||||
|
||||
| `default_output_dir` | Path |
|
||||
|----------------------|------|
|
||||
| `same-dir` | `{article-dir}/` |
|
||||
| `imgs-subdir` | `{article-dir}/imgs/` |
|
||||
| `illustrations-subdir` | `{article-dir}/illustrations/` |
|
||||
| `independent` | `illustrations/{topic-slug}/` |
|
||||
|
||||
### 1.5 Load Preferences (EXTEND.md) ⛔ BLOCKING
|
||||
|
||||
**CRITICAL**: If EXTEND.md not found, MUST complete first-time setup before ANY other questions or steps. Do NOT proceed to reference images, do NOT ask about content, do NOT ask about type/style — ONLY complete the preferences setup first.
|
||||
|
||||
```bash
|
||||
test -f .baoyu-skills/baoyu-article-illustrator/EXTEND.md && echo "project"
|
||||
test -f "$HOME/.baoyu-skills/baoyu-article-illustrator/EXTEND.md" && echo "user"
|
||||
```
|
||||
|
||||
| Result | Action |
|
||||
|--------|--------|
|
||||
| Found | Read, parse, display summary → Continue |
|
||||
| Not found | ⛔ **BLOCKING**: Run first-time setup ONLY ([config/first-time-setup.md](config/first-time-setup.md)) → Complete and save EXTEND.md → Then continue |
|
||||
|
||||
**Supports**: Watermark | Preferred type/style | Custom styles | Language | Output directory
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Setup & Analyze
|
||||
|
||||
### 2.1 Analyze Content
|
||||
|
||||
| Analysis | Description |
|
||||
|----------|-------------|
|
||||
| Content type | Technical / Tutorial / Methodology / Narrative |
|
||||
| Illustration purpose | information / visualization / imagination |
|
||||
| Core arguments | 2-5 main points to visualize |
|
||||
| Visual opportunities | Positions where illustrations add value |
|
||||
| Recommended type | Based on content signals and purpose |
|
||||
| Recommended density | Based on length and complexity |
|
||||
|
||||
### 2.2 Extract Core Arguments
|
||||
|
||||
- Main thesis
|
||||
- Key concepts reader needs
|
||||
- Comparisons/contrasts
|
||||
- Framework/model proposed
|
||||
|
||||
**CRITICAL**: If article uses metaphors (e.g., "电锯切西瓜"), do NOT illustrate literally. Visualize the **underlying concept**.
|
||||
|
||||
### 2.3 Identify Positions
|
||||
|
||||
**Illustrate**:
|
||||
- Core arguments (REQUIRED)
|
||||
- Abstract concepts
|
||||
- Data comparisons
|
||||
- Processes, workflows
|
||||
|
||||
**Do NOT Illustrate**:
|
||||
- Metaphors literally
|
||||
- Decorative scenes
|
||||
- Generic illustrations
|
||||
|
||||
### 2.4 Analyze Reference Images (if provided in Step 1.0)
|
||||
|
||||
For each reference image:
|
||||
|
||||
| Analysis | Description |
|
||||
|----------|-------------|
|
||||
| Visual characteristics | Style, colors, composition |
|
||||
| Content/subject | What the reference depicts |
|
||||
| Suitable positions | Which sections match this reference |
|
||||
| Style match | Which illustration types/styles align |
|
||||
| Usage recommendation | `direct` / `style` / `palette` |
|
||||
|
||||
| Usage | When to Use |
|
||||
|-------|-------------|
|
||||
| `direct` | Reference matches desired output closely |
|
||||
| `style` | Extract visual style characteristics only |
|
||||
| `palette` | Extract color scheme only |
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Confirm Settings ⚠️
|
||||
|
||||
**Do NOT skip.** Use ONE AskUserQuestion call with max 4 questions. **Q1, Q2, Q3 are ALL REQUIRED.**
|
||||
|
||||
### Q1: Illustration Type ⚠️ REQUIRED
|
||||
- [Recommended based on analysis] (Recommended)
|
||||
- infographic / scene / flowchart / comparison / framework / timeline / mixed
|
||||
|
||||
### Q2: Density ⚠️ REQUIRED - DO NOT SKIP
|
||||
- minimal (1-2) - Core concepts only
|
||||
- balanced (3-5) - Major sections
|
||||
- per-section - At least 1 per section/chapter (Recommended)
|
||||
- rich (6+) - Comprehensive coverage
|
||||
|
||||
### Q3: Style ⚠️ REQUIRED (ALWAYS ask, even with preferred_style in EXTEND.md)
|
||||
|
||||
If EXTEND.md has `preferred_style`:
|
||||
- [Custom style name + brief description] (Recommended)
|
||||
- [Top compatible core style 1]
|
||||
- [Top compatible core style 2]
|
||||
- Other (see full Style Gallery)
|
||||
|
||||
If no `preferred_style` (present Core Styles first):
|
||||
- [Best compatible core style] (Recommended)
|
||||
- [Other compatible core style 1]
|
||||
- [Other compatible core style 2]
|
||||
- Other (see full Style Gallery)
|
||||
|
||||
**Core Styles** (simplified selection):
|
||||
|
||||
| Core Style | Best For |
|
||||
|------------|----------|
|
||||
| `minimal-flat` | General, knowledge sharing, SaaS |
|
||||
| `sci-fi` | AI, frontier tech, system design |
|
||||
| `hand-drawn` | Relaxed, reflective, casual |
|
||||
| `editorial` | Processes, data, journalism |
|
||||
| `scene` | Narratives, emotional, lifestyle |
|
||||
|
||||
Style selection based on Type × Style compatibility matrix (styles.md).
|
||||
Full specs: `styles/<style>.md`
|
||||
|
||||
### Q4: Image Text Language ⚠️ REQUIRED when article language ≠ EXTEND.md `language`
|
||||
|
||||
Detect article language from content. If different from EXTEND.md `language` setting, MUST ask:
|
||||
- Article language (match article content) (Recommended)
|
||||
- EXTEND.md language (user's general preference)
|
||||
|
||||
**Skip only if**: Article language matches EXTEND.md `language`, or EXTEND.md has no `language` setting.
|
||||
|
||||
### Display Reference Usage (if references detected in Step 1.0)
|
||||
|
||||
When presenting outline preview to user, show reference assignments:
|
||||
|
||||
```
|
||||
Reference Images:
|
||||
| Ref | Filename | Recommended Usage |
|
||||
|-----|----------|-------------------|
|
||||
| 01 | 01-ref-diagram.png | direct → Illustration 1, 3 |
|
||||
| 02 | 02-ref-chart.png | palette → Illustration 2 |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Generate Outline
|
||||
|
||||
Save as `outline.md`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: infographic
|
||||
density: balanced
|
||||
style: blueprint
|
||||
image_count: 4
|
||||
references: # Only if references provided
|
||||
- ref_id: 01
|
||||
filename: 01-ref-diagram.png
|
||||
description: "Technical diagram showing system architecture"
|
||||
- ref_id: 02
|
||||
filename: 02-ref-chart.png
|
||||
description: "Color chart with brand palette"
|
||||
---
|
||||
|
||||
## Illustration 1
|
||||
|
||||
**Position**: [section] / [paragraph]
|
||||
**Purpose**: [why this helps]
|
||||
**Visual Content**: [what to show]
|
||||
**Type Application**: [how type applies]
|
||||
**References**: [01] # Optional: list ref_ids used
|
||||
**Reference Usage**: direct # direct | style | palette
|
||||
**Filename**: 01-infographic-concept-name.png
|
||||
|
||||
## Illustration 2
|
||||
...
|
||||
```
|
||||
|
||||
**Requirements**:
|
||||
- Each position justified by content needs
|
||||
- Type applied consistently
|
||||
- Style reflected in descriptions
|
||||
- Count matches density
|
||||
- References assigned based on Step 2.4 analysis
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Generate Images
|
||||
|
||||
### 5.1 Create Prompts ⛔ BLOCKING
|
||||
|
||||
**Every illustration MUST have a saved prompt file before generation begins. DO NOT skip this step.**
|
||||
|
||||
For each illustration in the outline:
|
||||
|
||||
1. **Create prompt file**: `prompts/NN-{type}-{slug}.md`
|
||||
2. **Include YAML frontmatter**:
|
||||
```yaml
|
||||
---
|
||||
illustration_id: 01
|
||||
type: infographic
|
||||
style: custom-flat-vector
|
||||
---
|
||||
```
|
||||
3. **Follow type-specific template** from [prompt-construction.md](prompt-construction.md)
|
||||
4. **Prompt quality requirements** (all REQUIRED):
|
||||
- `Layout`: Describe overall composition (grid / radial / hierarchical / left-right / top-down)
|
||||
- `ZONES`: Describe each visual area with specific content, not vague descriptions
|
||||
- `LABELS`: Use **actual numbers, terms, metrics, quotes from the article** — NOT generic placeholders
|
||||
- `COLORS`: Specify hex codes with semantic meaning (e.g., `Coral (#E07A5F) for emphasis`)
|
||||
- `STYLE`: Describe line treatment, texture, mood, character rendering
|
||||
- `ASPECT`: Specify ratio (e.g., `16:9`)
|
||||
5. **Apply defaults**: composition requirements, character rendering, text guidelines, watermark
|
||||
6. **Backup rule**: If prompt file exists, rename to `prompts/NN-{type}-{slug}-backup-YYYYMMDD-HHMMSS.md`
|
||||
|
||||
**Verification** ⛔: Before proceeding to 5.2, confirm ALL prompt files exist:
|
||||
```
|
||||
Prompt Files:
|
||||
- prompts/01-infographic-overview.md ✓
|
||||
- prompts/02-infographic-distillation.md ✓
|
||||
...
|
||||
```
|
||||
|
||||
**DO NOT** pass ad-hoc inline text to `--prompt` without first saving prompt files. The generation command should either use `--promptfiles prompts/NN-{type}-{slug}.md` or read the saved file content for `--prompt`.
|
||||
|
||||
**CRITICAL - References in Frontmatter**:
|
||||
- Only add `references` field if files ACTUALLY EXIST in `references/` directory
|
||||
- If style/palette was extracted verbally (no file), append info to prompt BODY instead
|
||||
- Before writing frontmatter, verify: `test -f references/NN-ref-{slug}.png`
|
||||
|
||||
### 5.2 Select Generation Skill
|
||||
|
||||
Check available skills. If multiple, ask user.
|
||||
|
||||
### 5.3 Process References ⚠️ REQUIRED if references saved in Step 1.0
|
||||
|
||||
**DO NOT SKIP if user provided reference images.** For each illustration with references:
|
||||
|
||||
1. **VERIFY files exist first**:
|
||||
```bash
|
||||
test -f references/NN-ref-{slug}.png && echo "exists" || echo "MISSING"
|
||||
```
|
||||
- If file MISSING but in frontmatter → ERROR, fix frontmatter or remove references field
|
||||
- If file exists → proceed with processing
|
||||
|
||||
2. Read prompt frontmatter for reference info
|
||||
3. Process based on usage type:
|
||||
|
||||
| Usage | Action | Example |
|
||||
|-------|--------|---------|
|
||||
| `direct` | Add reference path to `--ref` parameter | `--ref references/01-ref-brand.png` |
|
||||
| `style` | Analyze reference, append style traits to prompt | "Style: clean lines, gradient backgrounds..." |
|
||||
| `palette` | Extract colors from reference, append to prompt | "Colors: #E8756D coral, #7ECFC0 mint..." |
|
||||
|
||||
4. Check image generation skill capability:
|
||||
|
||||
| Skill Supports `--ref` | Action |
|
||||
|------------------------|--------|
|
||||
| Yes (e.g., baoyu-image-gen with Google) | Pass reference images via `--ref` |
|
||||
| No | Convert to text description, append to prompt |
|
||||
|
||||
**Verification**: Before generating, confirm reference processing:
|
||||
```
|
||||
Reference Processing:
|
||||
- Illustration 1: using 01-ref-brand.png (direct) ✓
|
||||
- Illustration 2: extracted palette from 02-ref-style.png ✓
|
||||
```
|
||||
|
||||
### 5.4 Apply Watermark (if enabled)
|
||||
|
||||
Add: `Include a subtle watermark "[content]" at [position].`
|
||||
|
||||
### 5.5 Generate
|
||||
|
||||
1. For each illustration:
|
||||
- **Backup rule**: If image file exists, rename to `NN-{type}-{slug}-backup-YYYYMMDD-HHMMSS.md`
|
||||
- If references with `direct` usage: include `--ref` parameter
|
||||
- Generate image
|
||||
2. After each: "Generated X/N"
|
||||
3. On failure: retry once, then log and continue
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Finalize
|
||||
|
||||
### 6.1 Update Article
|
||||
|
||||
Insert after corresponding paragraph:
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
Alt text: concise description in article's language.
|
||||
|
||||
### 6.2 Output Summary
|
||||
|
||||
```
|
||||
Article Illustration Complete!
|
||||
|
||||
Article: [path]
|
||||
Type: [type] | Density: [level] | Style: [style]
|
||||
Location: [directory]
|
||||
Images: X/N generated
|
||||
|
||||
Positions:
|
||||
- 01-xxx.png → After "[Section]"
|
||||
- 02-yyy.png → After "[Section]"
|
||||
|
||||
[If failures]
|
||||
Failed:
|
||||
- NN-zzz.png: [reason]
|
||||
```
|
||||
@@ -153,7 +153,11 @@ Technical terms remain in English.
|
||||
|
||||
```
|
||||
Comic Progress:
|
||||
- [ ] Step 1: Setup & Analyze (1.1 Preferences, 1.2 Analyze, 1.3 Check existing)
|
||||
- [ ] Step 1: Setup & Analyze
|
||||
- [ ] 1.1 Preferences (EXTEND.md) ⛔ BLOCKING
|
||||
- [ ] Found → load preferences → continue
|
||||
- [ ] Not found → run first-time setup → MUST complete before other steps
|
||||
- [ ] 1.2 Analyze, 1.3 Check existing
|
||||
- [ ] Step 2: Confirmation - Style & options ⚠️ REQUIRED
|
||||
- [ ] Step 3: Generate storyboard + characters
|
||||
- [ ] Step 4: Review outline (conditional)
|
||||
@@ -169,14 +173,22 @@ Comic Progress:
|
||||
### Flow
|
||||
|
||||
```
|
||||
Input → Preferences → Analyze → [Check Existing?] → [Confirm: Style + Reviews] → Storyboard → [Review?] → Prompts → [Review?] → Images → PDF → Complete
|
||||
Input → [Preferences] ─┬─ Found → Continue
|
||||
│
|
||||
└─ Not found → First-Time Setup ⛔ BLOCKING
|
||||
│
|
||||
└─ Complete setup → Save EXTEND.md → Continue
|
||||
│
|
||||
┌─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
Analyze → [Check Existing?] → [Confirm: Style + Reviews] → Storyboard → [Review?] → Prompts → [Review?] → Images → PDF → Complete
|
||||
```
|
||||
|
||||
### Step Summary
|
||||
|
||||
| Step | Action | Key Output |
|
||||
|------|--------|------------|
|
||||
| 1.1 | Load EXTEND.md preferences | Config loaded |
|
||||
| 1.1 | Load EXTEND.md preferences ⛔ BLOCKING if not found | Config loaded |
|
||||
| 1.2 | Analyze content | `analysis.md` |
|
||||
| 1.3 | Check existing directory | Handle conflicts |
|
||||
| 2 | Confirm style, focus, audience, reviews | User preferences |
|
||||
@@ -194,6 +206,7 @@ Input → Preferences → Analyze → [Check Existing?] → [Confirm: Style + Re
|
||||
**Character reference is MANDATORY for visual consistency.**
|
||||
|
||||
**7.1 Generate character sheet first**:
|
||||
- **Backup rule**: If `characters/characters.png` exists, rename to `characters/characters-backup-YYYYMMDD-HHMMSS.png`
|
||||
```bash
|
||||
# Use Reference Sheet Prompt from characters/characters.md
|
||||
npx -y bun ${SKILL_DIR}/../baoyu-image-gen/scripts/main.ts \
|
||||
@@ -214,6 +227,10 @@ Compress to reduce token usage when used as reference image:
|
||||
| Supports `--ref` | Pass `characters/characters.png` with EVERY page |
|
||||
| No `--ref` support | Prepend character descriptions to EVERY prompt file |
|
||||
|
||||
**Backup rules for page generation**:
|
||||
- If prompt file exists: rename to `prompts/NN-{cover|page}-[slug]-backup-YYYYMMDD-HHMMSS.md`
|
||||
- If image file exists: rename to `NN-{cover|page}-[slug]-backup-YYYYMMDD-HHMMSS.png`
|
||||
|
||||
```bash
|
||||
# Example: ALWAYS include --ref for consistency
|
||||
npx -y bun ${SKILL_DIR}/../baoyu-image-gen/scripts/main.ts \
|
||||
@@ -224,13 +241,20 @@ npx -y bun ${SKILL_DIR}/../baoyu-image-gen/scripts/main.ts \
|
||||
|
||||
**Full workflow details**: [references/workflow.md](references/workflow.md)
|
||||
|
||||
### EXTEND.md Paths
|
||||
### EXTEND.md Paths ⛔ BLOCKING
|
||||
|
||||
**CRITICAL**: If EXTEND.md not found, MUST complete first-time setup before ANY other questions or steps. Do NOT proceed to content analysis, do NOT ask about art style, do NOT ask about tone — ONLY complete the preferences setup first.
|
||||
|
||||
| Path | Location |
|
||||
|------|----------|
|
||||
| `.baoyu-skills/baoyu-comic/EXTEND.md` | Project directory |
|
||||
| `$HOME/.baoyu-skills/baoyu-comic/EXTEND.md` | User home |
|
||||
|
||||
| Result | Action |
|
||||
|--------|--------|
|
||||
| Found | Read, parse, display summary → Continue |
|
||||
| Not found | ⛔ **BLOCKING**: Run first-time setup ONLY ([references/config/first-time-setup.md](references/config/first-time-setup.md)) → Complete and save EXTEND.md → Then continue |
|
||||
|
||||
**EXTEND.md Supports**: Watermark | Preferred art/tone/layout | Custom style definitions | Character presets | Language preference
|
||||
|
||||
Schema: [references/config/preferences-schema.md](references/config/preferences-schema.md)
|
||||
|
||||
@@ -9,6 +9,14 @@ description: First-time setup flow for baoyu-comic preferences
|
||||
|
||||
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/source material
|
||||
- Ask about art style or tone
|
||||
- Ask about layout preferences
|
||||
- Proceed to content analysis
|
||||
|
||||
ONLY ask the questions in this setup flow, save EXTEND.md, then continue.
|
||||
|
||||
## Setup Flow
|
||||
|
||||
```
|
||||
|
||||
@@ -84,6 +84,7 @@ Read source content, save it if needed, and perform deep analysis.
|
||||
1. **Save source content** (if not already a file):
|
||||
- If user provides a file path: use as-is
|
||||
- If user pastes content: save to `source.md` in target directory
|
||||
- **Backup rule**: If `source.md` exists, rename to `source-backup-YYYYMMDD-HHMMSS.md`
|
||||
2. Read source content
|
||||
3. **Deep analysis** following `analysis-framework.md`:
|
||||
- Target audience identification
|
||||
@@ -329,6 +330,7 @@ Create image generation prompts for all pages.
|
||||
1. Create prompt following art style + tone guidelines
|
||||
2. Include character visual descriptions for consistency
|
||||
3. Save to `prompts/NN-{cover|page}-[slug].md`
|
||||
- **Backup rule**: If prompt file exists, rename to `prompts/NN-{cover|page}-[slug]-backup-YYYYMMDD-HHMMSS.md`
|
||||
|
||||
**Prompt File Format**:
|
||||
```markdown
|
||||
@@ -405,8 +407,9 @@ With confirmed prompts from Step 5/6:
|
||||
### 7.1 Generate Character Reference Sheet (first)
|
||||
|
||||
1. Use Reference Sheet Prompt from `characters/characters.md`
|
||||
2. Generate → `characters/characters.png`
|
||||
3. This ensures visual consistency for all subsequent pages
|
||||
2. **Backup rule**: If `characters/characters.png` exists, rename to `characters/characters-backup-YYYYMMDD-HHMMSS.png`
|
||||
3. Generate → `characters/characters.png`
|
||||
4. This ensures visual consistency for all subsequent pages
|
||||
|
||||
### 7.2 Generate Comic Pages
|
||||
|
||||
@@ -453,9 +456,10 @@ When skill does NOT support reference images, create combined prompt files:
|
||||
|
||||
**For each page (cover + pages)**:
|
||||
1. Read prompt from `prompts/NN-{cover|page}-[slug].md`
|
||||
2. Generate image using Strategy A or B (based on skill capability)
|
||||
3. Save to `NN-{cover|page}-[slug].png`
|
||||
4. Report progress after each generation: "Generated X/N: [page title]"
|
||||
2. **Backup rule**: If image file exists, rename to `NN-{cover|page}-[slug]-backup-YYYYMMDD-HHMMSS.png`
|
||||
3. Generate image using Strategy A or B (based on skill capability)
|
||||
4. Save to `NN-{cover|page}-[slug].png`
|
||||
5. Report progress after each generation: "Generated X/N: [page title]"
|
||||
|
||||
**Session Management**:
|
||||
If image generation skill supports `--sessionId`:
|
||||
|
||||
+106
-163
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: baoyu-cover-image
|
||||
description: Generates article cover images with 5 dimensions (type, palette, rendering, text, mood) combining 9 color palettes and 6 rendering styles. Supports cinematic (2.35:1), widescreen (16:9), and square (1:1) aspects. Use when user asks to "generate cover image", "create article cover", "make cover", or mentions "封面图".
|
||||
description: Generates article cover images with 5 dimensions (type, palette, rendering, text, mood) combining 9 color palettes and 6 rendering styles. Supports cinematic (2.35:1), widescreen (16:9), and square (1:1) aspects. Use when user asks to "generate cover image", "create article cover", or "make cover".
|
||||
---
|
||||
|
||||
# Cover Image Generator
|
||||
@@ -10,29 +10,23 @@ Generate elegant cover images for articles with 5-dimensional customization.
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Auto-select all dimensions based on content
|
||||
# Auto-select dimensions based on content
|
||||
/baoyu-cover-image path/to/article.md
|
||||
|
||||
# Quick mode: skip confirmation, use auto-selection
|
||||
# Quick mode: skip confirmation
|
||||
/baoyu-cover-image article.md --quick
|
||||
|
||||
# Specify dimensions (new 5D system)
|
||||
# Specify dimensions
|
||||
/baoyu-cover-image article.md --type conceptual --palette warm --rendering flat-vector
|
||||
/baoyu-cover-image article.md --text title-subtitle --mood bold
|
||||
|
||||
# Style presets (backward-compatible shorthand for palette + rendering)
|
||||
# Style presets (shorthand for palette + rendering)
|
||||
/baoyu-cover-image article.md --style blueprint
|
||||
/baoyu-cover-image article.md --style blueprint --rendering hand-drawn # override rendering
|
||||
|
||||
# Visual only (no title text)
|
||||
/baoyu-cover-image article.md --no-title
|
||||
# With reference images
|
||||
/baoyu-cover-image article.md --ref style-ref.png
|
||||
|
||||
# Direct content input
|
||||
/baoyu-cover-image
|
||||
[paste content]
|
||||
|
||||
# Direct input with options
|
||||
/baoyu-cover-image --palette mono --rendering digital --aspect 1:1 --quick
|
||||
/baoyu-cover-image --palette mono --aspect 1:1 --quick
|
||||
[paste content]
|
||||
```
|
||||
|
||||
@@ -40,115 +34,70 @@ Generate elegant cover images for articles with 5-dimensional customization.
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--type <name>` | Cover type: hero, conceptual, typography, metaphor, scene, minimal |
|
||||
| `--palette <name>` | Color palette: warm, elegant, cool, dark, earth, vivid, pastel, mono, retro |
|
||||
| `--rendering <name>` | Rendering style: flat-vector, hand-drawn, painterly, digital, pixel, chalk |
|
||||
| `--style <name>` | Preset shorthand (expands to palette + rendering, see [Style Presets](references/style-presets.md)) |
|
||||
| `--text <level>` | Text density: none, title-only, title-subtitle, text-rich |
|
||||
| `--mood <level>` | Emotional intensity: subtle, balanced, bold |
|
||||
| `--type <name>` | hero, conceptual, typography, metaphor, scene, minimal |
|
||||
| `--palette <name>` | warm, elegant, cool, dark, earth, vivid, pastel, mono, retro |
|
||||
| `--rendering <name>` | flat-vector, hand-drawn, painterly, digital, pixel, chalk |
|
||||
| `--style <name>` | Preset shorthand (see [Style Presets](references/style-presets.md)) |
|
||||
| `--text <level>` | none, title-only, title-subtitle, text-rich |
|
||||
| `--mood <level>` | subtle, balanced, bold |
|
||||
| `--font <name>` | clean, handwritten, serif, display |
|
||||
| `--aspect <ratio>` | 16:9 (default), 2.35:1, 4:3, 3:2, 1:1, 3:4 |
|
||||
| `--lang <code>` | Title language (en, zh, ja, etc.) |
|
||||
| `--no-title` | Alias for `--text none` |
|
||||
| `--quick` | Skip confirmation, use auto-selection for missing dimensions |
|
||||
| `--quick` | Skip confirmation, use auto-selection |
|
||||
| `--ref <files...>` | Reference images for style/composition guidance |
|
||||
|
||||
## Five Dimensions
|
||||
|
||||
| Dimension | Controls | Values | Default |
|
||||
|-----------|----------|--------|---------|
|
||||
| **Type** | Visual composition, information structure | hero, conceptual, typography, metaphor, scene, minimal | auto |
|
||||
| **Palette** | Colors, color scheme, decorative hints | warm, elegant, cool, dark, earth, vivid, pastel, mono, retro | auto |
|
||||
| **Rendering** | Line quality, texture, depth, element style | flat-vector, hand-drawn, painterly, digital, pixel, chalk | auto |
|
||||
| **Text** | Text density, information hierarchy | none, title-only, title-subtitle, text-rich | title-only |
|
||||
| **Mood** | Emotional intensity, visual weight | subtle, balanced, bold | balanced |
|
||||
| Dimension | Values | Default |
|
||||
|-----------|--------|---------|
|
||||
| **Type** | hero, conceptual, typography, metaphor, scene, minimal | auto |
|
||||
| **Palette** | warm, elegant, cool, dark, earth, vivid, pastel, mono, retro | auto |
|
||||
| **Rendering** | flat-vector, hand-drawn, painterly, digital, pixel, chalk | auto |
|
||||
| **Text** | none, title-only, title-subtitle, text-rich | title-only |
|
||||
| **Mood** | subtle, balanced, bold | balanced |
|
||||
| **Font** | clean, handwritten, serif, display | clean |
|
||||
|
||||
Dimensions can be freely combined. Auto-selection rules: [references/auto-selection.md](references/auto-selection.md)
|
||||
Auto-selection rules: [references/auto-selection.md](references/auto-selection.md)
|
||||
|
||||
## Type Gallery
|
||||
## Galleries
|
||||
|
||||
| Type | Description | Best For |
|
||||
|------|-------------|----------|
|
||||
| `hero` | Large visual impact, title overlay | Product launch, brand promotion, major announcements |
|
||||
| `conceptual` | Concept visualization, abstract core ideas | Technical articles, methodology, architecture design |
|
||||
| `typography` | Text-focused layout, prominent title | Opinion pieces, quotes, insights |
|
||||
| `metaphor` | Visual metaphor, concrete expressing abstract | Philosophy, growth, personal development |
|
||||
| `scene` | Atmospheric scene, narrative feel | Stories, travel, lifestyle |
|
||||
| `minimal` | Minimalist composition, generous whitespace | Zen, focus, core concepts |
|
||||
**Types**: hero, conceptual, typography, metaphor, scene, minimal
|
||||
→ Details: [references/types.md](references/types.md)
|
||||
|
||||
Type composition details: [references/types.md](references/types.md)
|
||||
**Palettes**: warm, elegant, cool, dark, earth, vivid, pastel, mono, retro
|
||||
→ Details: [references/palettes/](references/palettes/)
|
||||
|
||||
## Palette Gallery
|
||||
**Renderings**: flat-vector, hand-drawn, painterly, digital, pixel, chalk
|
||||
→ Details: [references/renderings/](references/renderings/)
|
||||
|
||||
| Palette | Vibe | Primary Colors |
|
||||
|---------|------|----------------|
|
||||
| `warm` | Friendly, approachable | Orange, golden yellow, terracotta |
|
||||
| `elegant` | Sophisticated, refined | Soft coral, muted teal, dusty rose |
|
||||
| `cool` | Technical, professional | Engineering blue, navy, cyan |
|
||||
| `dark` | Cinematic, premium | Electric purple, cyan, magenta |
|
||||
| `earth` | Natural, organic | Forest green, sage, earth brown |
|
||||
| `vivid` | Energetic, bold | Bright red, neon green, electric blue |
|
||||
| `pastel` | Gentle, whimsical | Soft pink, mint, lavender |
|
||||
| `mono` | Clean, focused | Black, near-black, white |
|
||||
| `retro` | Nostalgic, vintage | Muted orange, dusty pink, maroon |
|
||||
**Text Levels**: none (pure visual) | title-only (default) | title-subtitle | text-rich (with tags)
|
||||
→ Details: [references/dimensions/text.md](references/dimensions/text.md)
|
||||
|
||||
Palette definitions: [references/palettes/](references/palettes/)
|
||||
**Mood Levels**: subtle (low contrast) | balanced (default) | bold (high contrast)
|
||||
→ Details: [references/dimensions/mood.md](references/dimensions/mood.md)
|
||||
|
||||
## Rendering Gallery
|
||||
|
||||
| Rendering | Description | Key Characteristics |
|
||||
|-----------|-------------|---------------------|
|
||||
| `flat-vector` | Clean modern vector | Uniform outlines, flat fills, geometric icons |
|
||||
| `hand-drawn` | Sketchy organic illustration | Imperfect strokes, paper texture, doodles |
|
||||
| `painterly` | Soft watercolor/paint | Brush strokes, color bleeds, soft edges |
|
||||
| `digital` | Polished modern digital | Precise edges, subtle gradients, UI components |
|
||||
| `pixel` | Retro 8-bit pixel art | Pixel grid, dithering, chunky shapes |
|
||||
| `chalk` | Chalk on blackboard | Chalk strokes, dust effects, board texture |
|
||||
|
||||
Rendering definitions: [references/renderings/](references/renderings/)
|
||||
|
||||
## Text & Mood
|
||||
|
||||
| Text Level | Title | Subtitle | Tags | Use Case |
|
||||
|------------|:-----:|:--------:|:----:|----------|
|
||||
| `none` | - | - | - | Pure visual, no text |
|
||||
| `title-only` | ✓ (≤8 字) | - | - | Simple headline (default) |
|
||||
| `title-subtitle` | ✓ | ✓ (≤15 字) | - | Title + supporting context |
|
||||
| `text-rich` | ✓ | ✓ | ✓ (2-4) | Information-dense |
|
||||
|
||||
| Mood | Contrast | Saturation | Weight | Use Case |
|
||||
|------|:--------:|:----------:|:------:|----------|
|
||||
| `subtle` | Low | Muted | Light | Corporate, thought leadership |
|
||||
| `balanced` | Medium | Normal | Medium | General articles (default) |
|
||||
| `bold` | High | Vivid | Heavy | Announcements, promotions |
|
||||
|
||||
Full guides: [references/dimensions/text.md](references/dimensions/text.md) | [references/dimensions/mood.md](references/dimensions/mood.md)
|
||||
|
||||
## Style Presets & Compatibility
|
||||
|
||||
- **Style Presets**: `--style X` expands to palette + rendering. See [references/style-presets.md](references/style-presets.md)
|
||||
- **Compatibility Matrices**: Palette×Rendering, Type×Rendering, Type×Text, Type×Mood. See [references/compatibility.md](references/compatibility.md)
|
||||
- ✓✓ = highly recommended | ✓ = compatible | ✗ = not recommended
|
||||
**Fonts**: clean (sans-serif) | handwritten | serif | display (bold decorative)
|
||||
→ Details: [references/dimensions/font.md](references/dimensions/font.md)
|
||||
|
||||
## File Structure
|
||||
|
||||
Output directory depends on `default_output_dir` preference:
|
||||
|
||||
| Preference | Output Path |
|
||||
|------------|-------------|
|
||||
| `same-dir` | `{article-dir}/` |
|
||||
| `imgs-subdir` | `{article-dir}/imgs/` |
|
||||
| `independent` (default) | `cover-image/{topic-slug}/` |
|
||||
| Pasted content | `cover-image/{topic-slug}/` (always) |
|
||||
Output directory per `default_output_dir` preference:
|
||||
- `same-dir`: `{article-dir}/`
|
||||
- `imgs-subdir`: `{article-dir}/imgs/`
|
||||
- `independent` (default): `cover-image/{topic-slug}/`
|
||||
|
||||
```
|
||||
<output-dir>/
|
||||
├── source-{slug}.{ext} # Source files (text, images, etc.)
|
||||
├── source-{slug}.{ext} # Source files
|
||||
├── refs/ # Reference images (if provided)
|
||||
│ ├── ref-01-{slug}.{ext}
|
||||
│ └── ref-01-{slug}.md # Description file
|
||||
├── prompts/cover.md # Generation prompt
|
||||
└── cover.png # Output image
|
||||
```
|
||||
|
||||
**Slug**: Extract main topic (2-4 words, kebab-case). Example: "The Future of AI" → `future-of-ai`
|
||||
**Conflict**: If directory exists, append timestamp: `{topic-slug}-YYYYMMDD-HHMMSS`
|
||||
**Source Files**: Copy all sources with naming `source-{slug}.{ext}` (multiple supported)
|
||||
**Slug**: 2-4 words, kebab-case. Conflict: append `-YYYYMMDD-HHMMSS`
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -156,9 +105,9 @@ Output directory depends on `default_output_dir` preference:
|
||||
|
||||
```
|
||||
Cover Image Progress:
|
||||
- [ ] Step 0: Check preferences (EXTEND.md) ⚠️ REQUIRED if not found
|
||||
- [ ] Step 1: Analyze content + determine output directory ⚠️ MUST ask if not configured
|
||||
- [ ] Step 2: Confirm options (5 dimensions) ⚠️ REQUIRED unless --quick or all specified
|
||||
- [ ] Step 0: Check preferences (EXTEND.md) ⛔ BLOCKING
|
||||
- [ ] Step 1: Analyze content + save refs + determine output dir
|
||||
- [ ] Step 2: Confirm options (6 dimensions) ⚠️ unless --quick
|
||||
- [ ] Step 3: Create prompt
|
||||
- [ ] Step 4: Generate image
|
||||
- [ ] Step 5: Completion report
|
||||
@@ -167,73 +116,67 @@ Cover Image Progress:
|
||||
### Flow
|
||||
|
||||
```
|
||||
Input → [Step 0: Preferences/Setup] → Analyze → [Output Dir ⚠️] → [Confirm: 5 Dimensions] → Prompt → Generate → Complete
|
||||
↓
|
||||
(skip if --quick or all specified)
|
||||
Input → [Step 0: Preferences] ─┬─ Found → Continue
|
||||
└─ Not found → First-Time Setup ⛔ BLOCKING → Save EXTEND.md → Continue
|
||||
↓
|
||||
Analyze + Save Refs → [Output Dir] → [Confirm: 6 Dimensions] → Prompt → Generate → Complete
|
||||
↓
|
||||
(skip if --quick or all specified)
|
||||
```
|
||||
|
||||
### Step 0: Load Preferences (EXTEND.md) ⚠️
|
||||
|
||||
**Purpose**: Load user preferences or run first-time setup. **Do NOT skip setup if EXTEND.md not found.**
|
||||
|
||||
Use Bash to check EXTEND.md existence (priority order):
|
||||
### Step 0: Load Preferences ⛔ BLOCKING
|
||||
|
||||
Check EXTEND.md existence (priority: project → user):
|
||||
```bash
|
||||
# Check project-level first
|
||||
test -f .baoyu-skills/baoyu-cover-image/EXTEND.md && echo "project"
|
||||
|
||||
# Then user-level (cross-platform: $HOME works on macOS/Linux/WSL)
|
||||
test -f "$HOME/.baoyu-skills/baoyu-cover-image/EXTEND.md" && echo "user"
|
||||
```
|
||||
|
||||
| Result | Action |
|
||||
|--------|--------|
|
||||
| Found | Read, parse, display preferences summary → Continue to Step 1 |
|
||||
| Not found | ⚠️ MUST run first-time setup ([references/config/first-time-setup.md](references/config/first-time-setup.md)) → Then continue to Step 1 |
|
||||
| Found | Load, display summary → Continue |
|
||||
| Not found | ⛔ Run first-time setup ([references/config/first-time-setup.md](references/config/first-time-setup.md)) → Save → Continue |
|
||||
|
||||
**Preferences Summary** (when found):
|
||||
|
||||
```
|
||||
Preferences loaded from [project/user]:
|
||||
• Watermark: [enabled/disabled] [content if enabled]
|
||||
• Type/Palette/Rendering: [value or "auto"]
|
||||
• Text: [value or "title-only"] | Mood: [value or "balanced"]
|
||||
• Aspect: [default_aspect] | Output: [dir or "not set — will ask in Step 1.5"]
|
||||
• Quick mode: [enabled/disabled] | Language: [value or "auto"]
|
||||
```
|
||||
|
||||
**EXTEND.md Supports**: Watermark | Preferred type | Preferred palette | Preferred rendering | Preferred text | Preferred mood | Default aspect ratio | Default output directory | Quick mode | Custom palette definitions | Language preference
|
||||
|
||||
Schema: [references/config/preferences-schema.md](references/config/preferences-schema.md)
|
||||
**CRITICAL**: If not found, complete setup BEFORE any other steps or questions.
|
||||
|
||||
### Step 1: Analyze Content
|
||||
|
||||
1. **Save source content** (if pasted, save to `source.md` in target directory; if file path, use as-is)
|
||||
2. **Content analysis**: Extract topic, core message, tone, keywords; identify visual metaphors; detect content type
|
||||
3. **Language detection**: Detect source language, note user's input language, compare with EXTEND.md preference
|
||||
4. **Determine output directory** per File Structure rules. If no `default_output_dir` preference + file path input, include in Step 2 Q4
|
||||
1. **Save reference images** (if provided) → [references/workflow/reference-images.md](references/workflow/reference-images.md)
|
||||
2. **Save source content** (if pasted, save to `source.md`)
|
||||
3. **Analyze content**: topic, tone, keywords, visual metaphors
|
||||
4. **Deep analyze references** ⚠️: Extract specific, concrete elements (see reference-images.md)
|
||||
5. **Detect language**: Compare source, user input, EXTEND.md preference
|
||||
6. **Determine output directory**: Per File Structure rules
|
||||
|
||||
### Step 2: Confirm Options ⚠️
|
||||
|
||||
Validate all 5 dimensions + aspect ratio. Full confirmation flow: [references/workflow/confirm-options.md](references/workflow/confirm-options.md)
|
||||
|
||||
**Skip Conditions**:
|
||||
Full confirmation flow: [references/workflow/confirm-options.md](references/workflow/confirm-options.md)
|
||||
|
||||
| Condition | Skipped | Still Asked |
|
||||
|-----------|---------|-------------|
|
||||
| `--quick` or `quick_mode: true` | 5 dimensions | Aspect ratio (unless `--aspect`) |
|
||||
| All 5 + `--aspect` specified | All | None |
|
||||
| `--quick` or `quick_mode: true` | 6 dimensions | Aspect ratio (unless `--aspect`) |
|
||||
| All 6 + `--aspect` specified | All | None |
|
||||
|
||||
### Step 3: Create Prompt
|
||||
|
||||
Save to `prompts/cover.md`. Full template: [references/workflow/prompt-template.md](references/workflow/prompt-template.md)
|
||||
Save to `prompts/cover.md`. Template: [references/workflow/prompt-template.md](references/workflow/prompt-template.md)
|
||||
|
||||
**CRITICAL - References in Frontmatter**:
|
||||
- Files saved to `refs/` → Add to frontmatter `references` list
|
||||
- Style extracted verbally (no file) → Omit `references`, describe in body
|
||||
- Before writing → Verify: `test -f refs/ref-NN-{slug}.{ext}`
|
||||
|
||||
**Reference elements in body** MUST be detailed, prefixed with "MUST"/"REQUIRED", with integration approach.
|
||||
|
||||
### Step 4: Generate Image
|
||||
|
||||
1. Backup existing `cover.png` → `cover-backup-YYYYMMDD-HHMMSS.png` (if regenerating)
|
||||
2. Check available image generation skills; if multiple, ask user preference
|
||||
3. Call selected skill with prompt file path, output path (`cover.png`), aspect ratio
|
||||
4. On failure: auto-retry once before reporting error
|
||||
1. **Backup existing** `cover.png` if regenerating
|
||||
2. **Check image generation skills**; if multiple, ask preference
|
||||
3. **Process references** from prompt frontmatter:
|
||||
- `direct` usage → pass via `--ref` (use ref-capable backend)
|
||||
- `style`/`palette` → extract traits, append to prompt
|
||||
4. **Generate**: Call skill with prompt file, output path, aspect ratio
|
||||
5. On failure: auto-retry once
|
||||
|
||||
### Step 5: Completion Report
|
||||
|
||||
@@ -242,49 +185,49 @@ Cover Generated!
|
||||
|
||||
Topic: [topic]
|
||||
Type: [type] | Palette: [palette] | Rendering: [rendering]
|
||||
Text: [text] | Mood: [mood] | Aspect: [ratio]
|
||||
Title: [title text or "visual only"]
|
||||
Text: [text] | Mood: [mood] | Font: [font] | Aspect: [ratio]
|
||||
Title: [title or "visual only"]
|
||||
Language: [lang] | Watermark: [enabled/disabled]
|
||||
References: [N images or "extracted style" or "none"]
|
||||
Location: [directory path]
|
||||
|
||||
Files:
|
||||
✓ source-{slug}.{ext}
|
||||
✓ prompts/cover.md
|
||||
✓ cover.png
|
||||
[✓ cover-backup-{timestamp}.png (if regenerated)]
|
||||
```
|
||||
|
||||
## Image Modification
|
||||
|
||||
| Action | Steps |
|
||||
|--------|-------|
|
||||
| **Regenerate** | Backup existing → **Update prompt file FIRST** → Regenerate with same settings |
|
||||
| **Change dimension** | Backup existing → Confirm new value → **Update prompt file FIRST** → Regenerate |
|
||||
| **Regenerate** | Backup → Update prompt file FIRST → Regenerate |
|
||||
| **Change dimension** | Backup → Confirm new value → Update prompt → Regenerate |
|
||||
|
||||
**IMPORTANT**: When regenerating, ALWAYS update the prompt file (`prompts/cover.md`) FIRST before regenerating. This ensures changes are documented and reproducible.
|
||||
## Composition Principles
|
||||
|
||||
All modifications automatically backup existing `cover.png` before regenerating.
|
||||
- **Whitespace**: 40-60% breathing room
|
||||
- **Visual anchor**: Main element centered or offset left
|
||||
- **Characters**: Simplified silhouettes; NO realistic humans
|
||||
- **Title**: Use exact title from user/source; never invent
|
||||
|
||||
## Notes
|
||||
## Extension Support
|
||||
|
||||
- Cover must be readable at small preview sizes
|
||||
- Visual metaphors > literal representations
|
||||
- Title: max 8 chars, readable, impactful
|
||||
- Two confirmation points: Step 0 (first-time setup) + Step 2 (options) - skip Step 2 with `--quick`
|
||||
- Use confirmed language for title text
|
||||
- Maintain watermark consistency if enabled
|
||||
- Check compatibility matrices when selecting combinations
|
||||
- `--no-title` is alias for `--text none`
|
||||
- `--style` presets are backward-compatible; explicit `--palette`/`--rendering` override preset values
|
||||
Custom configurations via EXTEND.md. See **Step 0** for paths.
|
||||
|
||||
Supports: Watermark | Preferred dimensions | Default aspect/output | Quick mode | Custom palettes | Language
|
||||
|
||||
Schema: [references/config/preferences-schema.md](references/config/preferences-schema.md)
|
||||
|
||||
## References
|
||||
|
||||
**Dimensions**: [text.md](references/dimensions/text.md) | [mood.md](references/dimensions/mood.md)
|
||||
**Dimensions**: [text.md](references/dimensions/text.md) | [mood.md](references/dimensions/mood.md) | [font.md](references/dimensions/font.md)
|
||||
**Palettes**: [references/palettes/](references/palettes/)
|
||||
**Renderings**: [references/renderings/](references/renderings/)
|
||||
**Types**: [references/types.md](references/types.md)
|
||||
**Auto-Selection**: [references/auto-selection.md](references/auto-selection.md)
|
||||
**Style Presets**: [references/style-presets.md](references/style-presets.md)
|
||||
**Compatibility**: [references/compatibility.md](references/compatibility.md)
|
||||
**Types**: [references/types.md](references/types.md)
|
||||
**Workflow**: [confirm-options.md](references/workflow/confirm-options.md) | [prompt-template.md](references/workflow/prompt-template.md)
|
||||
**Visual Elements**: [references/visual-elements.md](references/visual-elements.md)
|
||||
**Workflow**: [confirm-options.md](references/workflow/confirm-options.md) | [prompt-template.md](references/workflow/prompt-template.md) | [reference-images.md](references/workflow/reference-images.md)
|
||||
**Config**: [preferences-schema.md](references/config/preferences-schema.md) | [first-time-setup.md](references/config/first-time-setup.md) | [watermark-guide.md](references/config/watermark-guide.md)
|
||||
|
||||
@@ -58,3 +58,14 @@ Default: `title-only`
|
||||
| Launch, announcement, promotion, event, gaming, entertainment | `bold` |
|
||||
|
||||
Default: `balanced`
|
||||
|
||||
## Auto Font Selection
|
||||
|
||||
| Signals | Font |
|
||||
|---------|------|
|
||||
| Personal, lifestyle, human, warm, friendly, story | `handwritten` |
|
||||
| Technical, professional, clean, modern, minimal, data | `clean` |
|
||||
| Editorial, academic, luxury, classic, literary | `serif` |
|
||||
| Announcement, entertainment, promotion, bold, event, gaming | `display` |
|
||||
|
||||
Default: `clean`
|
||||
|
||||
@@ -39,8 +39,8 @@ Apply the specified rendering's characteristics:
|
||||
|
||||
### Text (Density Level)
|
||||
- `none`: No text elements, full visual area
|
||||
- `title-only`: Single headline (≤8 characters), 85% visual area
|
||||
- `title-subtitle`: Title + context (≤15 chars), 75% visual area
|
||||
- `title-only`: Single headline, 85% visual area
|
||||
- `title-subtitle`: Title + context, 75% visual area
|
||||
- `text-rich`: Title + subtitle + 2-4 keyword tags, 60% visual area
|
||||
|
||||
### Mood (Emotional Intensity)
|
||||
@@ -50,17 +50,44 @@ Apply the specified rendering's characteristics:
|
||||
|
||||
## Text Style (When Title Included)
|
||||
|
||||
- Title text: Large, eye-catching, max 8 characters
|
||||
- Subtitle: Secondary, max 15 characters (if title-subtitle or text-rich)
|
||||
- **Title source**: Use the exact title provided by user, or extract from source content. Do NOT invent or modify titles.
|
||||
- Title text: Large, eye-catching, faithful to source
|
||||
- Subtitle: Secondary element (if title-subtitle or text-rich)
|
||||
- Tags: 2-4 keyword badges (if text-rich)
|
||||
- Font style harmonizes with rendering style
|
||||
- **Engagement hooks**: Use numbers ("3个关键"), questions ("Why?"), contrasts ("A vs B"), pain points ("别再X了"), or suspense ("隐藏的X") for compelling titles
|
||||
|
||||
## Composition Guidance
|
||||
|
||||
**Icon Vocabulary**: Represent concepts with simple, recognizable icons rather than detailed illustrations. Use the rendering style to determine icon complexity (flat-vector = geometric, hand-drawn = sketchy, etc.)
|
||||
### Layout Principles
|
||||
|
||||
**Character Handling**: When people are needed, use simplified silhouettes or abstract representations. NO realistic faces, detailed anatomy, or photographic representations.
|
||||
- **Generous whitespace**: Maintain 40-60% breathing room; avoid cluttered compositions
|
||||
- **Visual anchor placement**: Main element centered or offset left (reserve right side for title if included)
|
||||
- **Information hierarchy**: One dominant focal point, 1-2 supporting elements, decorative accents
|
||||
- **Clean backgrounds**: Solid colors or subtle gradients; no complex textures or patterns
|
||||
|
||||
### Icon & Symbol Vocabulary
|
||||
|
||||
Represent concepts with simple, recognizable icons rather than detailed illustrations:
|
||||
|
||||
| Category | Examples |
|
||||
|----------|----------|
|
||||
| Tech | Code window, gear, circuit, cloud, lock, API brackets |
|
||||
| Ideas | Lightbulb, rocket, target, puzzle, key, magnifier |
|
||||
| Communication | Speech bubble, chat dots, megaphone, mail |
|
||||
| Growth | Plant/sprout, tree, arrow, chart, mountain |
|
||||
| Tools | Wrench, pencil, brush, checklist, clock |
|
||||
|
||||
Use the rendering style to determine icon complexity (flat-vector = geometric, hand-drawn = sketchy, etc.)
|
||||
|
||||
Full library: [references/visual-elements.md](visual-elements.md)
|
||||
|
||||
### Character Handling
|
||||
|
||||
When people are needed:
|
||||
- Use simplified silhouettes or abstract stick figures
|
||||
- Symbolic representations (head + shoulders outline)
|
||||
- NO realistic faces, detailed anatomy, or photographic representations
|
||||
- Cartoon/icon style consistent with rendering choice
|
||||
|
||||
## Mood Application
|
||||
|
||||
@@ -77,6 +104,16 @@ Apply mood adjustments to the base palette:
|
||||
- Use the same language as the content provided below for any text elements
|
||||
- Match punctuation style to the content language
|
||||
|
||||
## Reference Images
|
||||
|
||||
When reference images are provided:
|
||||
|
||||
- **Style extraction**: Identify rendering technique, line quality, texture, and visual vocabulary
|
||||
- **Composition learning**: Note layout patterns, whitespace usage, element placement
|
||||
- **Mood matching**: Capture the emotional tone and visual weight
|
||||
- **Adaptation**: Apply extracted characteristics while respecting the specified Type, Palette, and Rendering dimensions
|
||||
- **Priority**: If reference style conflicts with specified dimensions, dimensions take precedence for structural choices; reference influences decorative details
|
||||
|
||||
---
|
||||
|
||||
Please generate the cover image based on the content provided below:
|
||||
|
||||
@@ -48,3 +48,12 @@
|
||||
| metaphor | ✓✓ | ✓✓ | ✓ |
|
||||
| scene | ✓✓ | ✓✓ | ✓ |
|
||||
| minimal | ✓✓ | ✓✓ | ✗ |
|
||||
|
||||
## Font × Rendering
|
||||
|
||||
| | flat-vector | hand-drawn | painterly | digital | pixel | chalk |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| clean | ✓✓ | ✗ | ✗ | ✓✓ | ✓ | ✗ |
|
||||
| handwritten | ✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✓✓ |
|
||||
| serif | ✓ | ✗ | ✓ | ✓✓ | ✗ | ✗ |
|
||||
| display | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
|
||||
@@ -9,6 +9,14 @@ description: First-time setup flow for baoyu-cover-image preferences
|
||||
|
||||
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 reference images
|
||||
- Ask about content/article
|
||||
- Ask about dimensions (type, palette, rendering)
|
||||
- Proceed to content analysis
|
||||
|
||||
ONLY ask the questions in this setup flow, save EXTEND.md, then continue.
|
||||
|
||||
## Setup Flow
|
||||
|
||||
```
|
||||
|
||||
@@ -103,8 +103,8 @@ custom_palettes:
|
||||
| Value | Description |
|
||||
|-------|-------------|
|
||||
| `none` | Pure visual, no text elements |
|
||||
| `title-only` | Single headline (≤8 characters) |
|
||||
| `title-subtitle` | Title + subtitle (≤15 characters) |
|
||||
| `title-only` | Single headline |
|
||||
| `title-subtitle` | Title + subtitle |
|
||||
| `text-rich` | Title + subtitle + keyword tags (2-4) |
|
||||
|
||||
## Mood Options
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
name: font-dimension
|
||||
description: Typography style dimension for cover images
|
||||
---
|
||||
|
||||
# Font Dimension
|
||||
|
||||
Controls typography style and character feel.
|
||||
|
||||
## Values
|
||||
|
||||
| Font | Visual Style | Line Quality | Character |
|
||||
|------|--------------|--------------|-----------|
|
||||
| `clean` | Geometric sans-serif | Sharp, uniform | Modern, precise, neutral |
|
||||
| `handwritten` | Hand-lettered, brush | Organic, varied | Warm, personal, friendly |
|
||||
| `serif` | Classic serifs, elegant | Refined, structured | Editorial, authoritative |
|
||||
| `display` | Bold, decorative | Heavy, expressive | Attention-grabbing, playful |
|
||||
|
||||
## Detail
|
||||
|
||||
### clean
|
||||
|
||||
Modern, universal typography with neutral character.
|
||||
|
||||
**Characteristics**:
|
||||
- Geometric sans-serif letterforms
|
||||
- Sharp, uniform line weight
|
||||
- Clean edges, no flourishes
|
||||
- High readability at all sizes
|
||||
- Minimal personality, maximum clarity
|
||||
|
||||
**Use Cases**:
|
||||
- Technical documentation
|
||||
- Professional/corporate content
|
||||
- Minimal design approaches
|
||||
- Data-driven articles
|
||||
- Modern brand aesthetics
|
||||
|
||||
**Prompt Hints**:
|
||||
- Use clean geometric sans-serif typography
|
||||
- Modern, minimal letterforms
|
||||
- Sharp edges, uniform stroke weight
|
||||
- High contrast against background
|
||||
|
||||
### handwritten
|
||||
|
||||
Warm, organic typography with personal character.
|
||||
|
||||
**Characteristics**:
|
||||
- Hand-lettered or brush style
|
||||
- Organic, varied line weight
|
||||
- Natural imperfections
|
||||
- Approachable, human feel
|
||||
- Casual yet intentional
|
||||
|
||||
**Use Cases**:
|
||||
- Personal stories
|
||||
- Lifestyle content
|
||||
- Wellness and self-improvement
|
||||
- Creative tutorials
|
||||
- Friendly brand voices
|
||||
|
||||
**Prompt Hints**:
|
||||
- Use warm hand-lettered typography with organic brush strokes
|
||||
- Friendly, personal feel
|
||||
- Natural variation in stroke weight
|
||||
- Approachable, human character
|
||||
|
||||
### serif
|
||||
|
||||
Classic, elegant typography with editorial authority.
|
||||
|
||||
**Characteristics**:
|
||||
- Traditional serif letterforms
|
||||
- Refined, structured strokes
|
||||
- Elegant proportions
|
||||
- Timeless sophistication
|
||||
- Formal, trustworthy feel
|
||||
|
||||
**Use Cases**:
|
||||
- Editorial content
|
||||
- Academic articles
|
||||
- Luxury brand content
|
||||
- Historical topics
|
||||
- Literary pieces
|
||||
|
||||
**Prompt Hints**:
|
||||
- Use elegant serif typography with refined letterforms
|
||||
- Classic, editorial character
|
||||
- Structured, proportional spacing
|
||||
- Authoritative, sophisticated feel
|
||||
|
||||
### display
|
||||
|
||||
Bold, decorative typography for maximum impact.
|
||||
|
||||
**Characteristics**:
|
||||
- Heavy, expressive letterforms
|
||||
- Decorative elements
|
||||
- Strong visual presence
|
||||
- Playful or dramatic character
|
||||
- Designed for headlines
|
||||
|
||||
**Use Cases**:
|
||||
- Announcements
|
||||
- Entertainment content
|
||||
- Promotional materials
|
||||
- Event marketing
|
||||
- Gaming topics
|
||||
|
||||
**Prompt Hints**:
|
||||
- Use bold decorative display typography
|
||||
- Heavy, expressive headlines
|
||||
- Strong visual impact
|
||||
- Attention-grabbing character
|
||||
|
||||
## Default
|
||||
|
||||
`clean` — Universal, pairs well with most rendering styles.
|
||||
|
||||
## Rendering Compatibility
|
||||
|
||||
| Font × Rendering | flat-vector | hand-drawn | painterly | digital | pixel | chalk |
|
||||
|------------------|:-----------:|:----------:|:---------:|:-------:|:-----:|:-----:|
|
||||
| clean | ✓✓ | ✗ | ✗ | ✓✓ | ✓ | ✗ |
|
||||
| handwritten | ✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✓✓ |
|
||||
| serif | ✓ | ✗ | ✓ | ✓✓ | ✗ | ✗ |
|
||||
| display | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
|
||||
✓✓ = highly recommended | ✓ = compatible | ✗ = not recommended
|
||||
|
||||
## Type Compatibility
|
||||
|
||||
| Font × Type | hero | conceptual | typography | metaphor | scene | minimal |
|
||||
|-------------|:----:|:----------:|:----------:|:--------:|:-----:|:-------:|
|
||||
| clean | ✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✓✓ |
|
||||
| handwritten | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
| serif | ✓ | ✓ | ✓✓ | ✓ | ✓ | ✓ |
|
||||
| display | ✓✓ | ✓ | ✓✓ | ✓ | ✓ | ✗ |
|
||||
|
||||
## Palette Interaction
|
||||
|
||||
Font style adapts to palette characteristics:
|
||||
|
||||
| Palette Category | clean | handwritten | serif | display |
|
||||
|------------------|-------|-------------|-------|---------|
|
||||
| Warm (warm, earth, pastel) | Softer weight | Natural fit | Warm tones | Playful energy |
|
||||
| Cool (cool, mono, elegant) | Perfect match | Contrast | Classic pairing | Bold statement |
|
||||
| Dark (dark, vivid) | High contrast | Glow effects | Dramatic | Maximum impact |
|
||||
| Vintage (retro) | Modern contrast | Nostalgic fit | Period-appropriate | Retro headlines |
|
||||
|
||||
## Auto Selection
|
||||
|
||||
When `--font` is omitted, select based on signals:
|
||||
|
||||
| Signals | Font |
|
||||
|---------|------|
|
||||
| Personal, lifestyle, human, warm, friendly, story | `handwritten` |
|
||||
| Technical, professional, clean, modern, minimal, data | `clean` |
|
||||
| Editorial, academic, luxury, classic, literary | `serif` |
|
||||
| Announcement, entertainment, promotion, bold, event, gaming | `display` |
|
||||
|
||||
Default: `clean`
|
||||
@@ -12,8 +12,8 @@ Controls text density and information hierarchy on cover images.
|
||||
| Value | Title | Subtitle | Tags | Visual Area |
|
||||
|-------|:-----:|:--------:|:----:|:-----------:|
|
||||
| `none` | - | - | - | 100% |
|
||||
| `title-only` | ✓ (≤8字) | - | - | 85% |
|
||||
| `title-subtitle` | ✓ | ✓ (≤15字) | - | 75% |
|
||||
| `title-only` | ✓ | - | - | 85% |
|
||||
| `title-subtitle` | ✓ | ✓ | - | 75% |
|
||||
| `text-rich` | ✓ | ✓ | ✓ (2-4) | 60% |
|
||||
|
||||
## Detail
|
||||
@@ -43,24 +43,14 @@ Single headline, maximum impact.
|
||||
- Strong brand recognition
|
||||
|
||||
**Composition**:
|
||||
- Title: ≤8 characters, prominent
|
||||
- Title: prominent placement
|
||||
- Reserved zone: top or bottom 15%
|
||||
- Visual supports title message
|
||||
|
||||
**Title Guidelines**:
|
||||
- Punchy, action-oriented
|
||||
- Use exact title from source content or user-provided title
|
||||
- Do NOT invent or modify titles
|
||||
- Match content language
|
||||
- Use engagement hooks (see below)
|
||||
|
||||
**Engagement Hooks**:
|
||||
|
||||
| Hook Type | Examples (EN) | Examples (ZH) |
|
||||
|-----------|---------------|---------------|
|
||||
| Numbers | "3 Traps", "5 Keys" | "3个陷阱", "5个关键" |
|
||||
| Questions | "Why X?", "How?" | "为什么X?", "怎么做?" |
|
||||
| Contrasts | "A vs B", "Old→New" | "A还是B", "旧→新" |
|
||||
| Pain Points | "Stop X", "Never Do" | "别再X了", "千万别" |
|
||||
| Suspense | "Hidden X", "Secret" | "隐藏的X", "秘密" |
|
||||
|
||||
### title-subtitle
|
||||
|
||||
@@ -72,14 +62,14 @@ Title with supporting context.
|
||||
- Content with dual messages
|
||||
|
||||
**Composition**:
|
||||
- Title: ≤8 characters, primary
|
||||
- Subtitle: ≤15 characters, secondary
|
||||
- Title: primary element
|
||||
- Subtitle: secondary element
|
||||
- Reserved zone: 25%
|
||||
- Clear hierarchy between title/subtitle
|
||||
|
||||
**Title Guidelines**:
|
||||
- Use engagement hooks: numbers, questions, contrasts, pain points, suspense
|
||||
- Punchy, action-oriented
|
||||
- Use exact title from source content or user-provided title
|
||||
- Do NOT invent or modify titles
|
||||
|
||||
**Subtitle Guidelines**:
|
||||
- Clarify or contextualize title
|
||||
@@ -104,8 +94,8 @@ Information-dense cover with multiple text elements.
|
||||
- Clear visual hierarchy
|
||||
|
||||
**Title Guidelines**:
|
||||
- Use engagement hooks: numbers, questions, contrasts, pain points, suspense
|
||||
- Punchy, action-oriented
|
||||
- Use exact title from source content or user-provided title
|
||||
- Do NOT invent or modify titles
|
||||
|
||||
**Tag Guidelines**:
|
||||
- 2-4 tags maximum
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
| `vector-illustration` | `retro` | `flat-vector` |
|
||||
| `vintage` | `retro` | `hand-drawn` |
|
||||
| `warm` | `warm` | `hand-drawn` |
|
||||
| `warm-flat` | `warm` | `flat-vector` |
|
||||
| `watercolor` | `earth` | `painterly` |
|
||||
|
||||
## Override Examples
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Visual Elements Library
|
||||
|
||||
Icon and symbol vocabulary organized by topic. Use these as building blocks for cover compositions.
|
||||
|
||||
## Tech & Development
|
||||
|
||||
| Element | Use For |
|
||||
|---------|---------|
|
||||
| Code window / Terminal | Programming, development |
|
||||
| Gear / Cog | Engineering, settings, process |
|
||||
| Circuit board / Chip | Hardware, AI, computing |
|
||||
| Binary / Data stream | Data, algorithms |
|
||||
| API brackets `</>` | Web development, APIs |
|
||||
| Cloud | Cloud computing, SaaS |
|
||||
| Lock / Shield | Security, privacy |
|
||||
| Network nodes | Distributed systems, connections |
|
||||
|
||||
## Ideas & Innovation
|
||||
|
||||
| Element | Use For |
|
||||
|---------|---------|
|
||||
| Lightbulb | Ideas, insights, innovation |
|
||||
| Rocket | Launch, growth, startups |
|
||||
| Target / Bullseye | Goals, precision, focus |
|
||||
| Puzzle piece | Problem solving, integration |
|
||||
| Key | Solutions, access, unlocking |
|
||||
| Magnifying glass | Analysis, search, discovery |
|
||||
| Chart / Graph | Data, trends, growth |
|
||||
| Arrow / Path | Direction, journey, progress |
|
||||
|
||||
## Communication & Collaboration
|
||||
|
||||
| Element | Use For |
|
||||
|---------|---------|
|
||||
| Speech bubble | Communication, dialogue |
|
||||
| Chat dots `...` | Conversation, messaging |
|
||||
| Handshake | Partnership, agreement |
|
||||
| Team / Figures | Collaboration, community |
|
||||
| Mail / Envelope | Notifications, outreach |
|
||||
| Megaphone | Announcements, marketing |
|
||||
| Network / Web | Social, connections |
|
||||
|
||||
## Nature & Growth
|
||||
|
||||
| Element | Use For |
|
||||
|---------|---------|
|
||||
| Plant / Sprout | Growth, organic, sustainability |
|
||||
| Tree | Established, structure, branching |
|
||||
| Leaf | Eco, natural, fresh |
|
||||
| Sun / Rays | Energy, positivity, new beginnings |
|
||||
| Mountain | Challenge, achievement, scale |
|
||||
| Wave | Flow, change, rhythm |
|
||||
| Seed → Plant | Transformation, potential |
|
||||
|
||||
## Tools & Actions
|
||||
|
||||
| Element | Use For |
|
||||
|---------|---------|
|
||||
| Wrench / Hammer | Building, fixing, tools |
|
||||
| Pencil / Pen | Writing, creation, editing |
|
||||
| Brush | Design, creativity, art |
|
||||
| Scissors | Cutting, editing, trimming |
|
||||
| Clock / Timer | Time, scheduling, deadlines |
|
||||
| Calendar | Planning, events, milestones |
|
||||
| Checklist / Checkbox | Tasks, completion, validation |
|
||||
|
||||
## Abstract Concepts
|
||||
|
||||
| Element | Use For |
|
||||
|---------|---------|
|
||||
| Infinity ∞ | Continuous, endless, loops |
|
||||
| Yin-yang | Balance, duality, harmony |
|
||||
| Spiral | Evolution, recursion, cycles |
|
||||
| Stack / Layers | Depth, hierarchy, structure |
|
||||
| Bridge | Connection, transition, spanning |
|
||||
| Door / Portal | Opportunity, entry, access |
|
||||
| Mirror / Reflection | Self-improvement, analysis |
|
||||
|
||||
## Combination Patterns
|
||||
|
||||
Create visual metaphors by combining elements:
|
||||
|
||||
| Combination | Represents |
|
||||
|-------------|------------|
|
||||
| Lightbulb + Gear | Innovative engineering |
|
||||
| Plant + Code | Organic tech growth |
|
||||
| Rocket + Target | Precise acceleration |
|
||||
| Key + Lock | Security solutions |
|
||||
| Bridge + People | Team connections |
|
||||
| Magnifier + Data | Analytics, insights |
|
||||
|
||||
## Rendering-Specific Treatment
|
||||
|
||||
| Rendering | Element Style |
|
||||
|-----------|---------------|
|
||||
| `flat-vector` | Geometric, simple shapes, uniform fills |
|
||||
| `hand-drawn` | Sketchy, organic, doodle-like |
|
||||
| `painterly` | Soft edges, brush strokes |
|
||||
| `digital` | Precise, gradient hints, polished |
|
||||
| `pixel` | 8-bit chunky, grid-aligned |
|
||||
| `chalk` | Dusty, textured, board style |
|
||||
@@ -2,22 +2,22 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
Validate all 5 dimensions + aspect ratio.
|
||||
Validate all 6 dimensions + aspect ratio.
|
||||
|
||||
## Skip Conditions
|
||||
|
||||
| Condition | Skipped Questions | Still Asked |
|
||||
|-----------|-------------------|-------------|
|
||||
| `--quick` flag | Type, Palette, Rendering, Text, Mood | **Aspect Ratio** (unless `--aspect` specified) |
|
||||
| All 5 dimensions + `--aspect` specified | All | None |
|
||||
| `quick_mode: true` in EXTEND.md | Type, Palette, Rendering, Text, Mood | **Aspect Ratio** (unless `--aspect` specified) |
|
||||
| Otherwise | None | All 6 questions |
|
||||
| `--quick` flag | Type, Palette, Rendering, Text, Mood, Font | **Aspect Ratio** (unless `--aspect` specified) |
|
||||
| All 6 dimensions + `--aspect` specified | All | None |
|
||||
| `quick_mode: true` in EXTEND.md | Type, Palette, Rendering, Text, Mood, Font | **Aspect Ratio** (unless `--aspect` specified) |
|
||||
| Otherwise | None | All 7 questions |
|
||||
|
||||
**Important**: Aspect ratio is ALWAYS asked unless explicitly specified via `--aspect` CLI flag. User presets in EXTEND.md are shown as recommended option, not auto-selected.
|
||||
|
||||
## Quick Mode Output
|
||||
|
||||
When skipping 5 dimensions:
|
||||
When skipping 6 dimensions:
|
||||
|
||||
```
|
||||
Quick Mode: Auto-selected dimensions
|
||||
@@ -26,8 +26,9 @@ Quick Mode: Auto-selected dimensions
|
||||
• Rendering: [rendering] ([reason])
|
||||
• Text: [text] ([reason])
|
||||
• Mood: [mood] ([reason])
|
||||
• Font: [font] ([reason])
|
||||
|
||||
[Then ask Question 6: Aspect Ratio]
|
||||
[Then ask Question 7: Aspect Ratio]
|
||||
```
|
||||
|
||||
## Confirmation Flow
|
||||
@@ -91,7 +92,26 @@ options:
|
||||
description: "Polished, precise, subtle gradients"
|
||||
```
|
||||
|
||||
### Q4: Other Settings (skip if all remaining dimensions already specified)
|
||||
### Q4: Font (skip if `--font`)
|
||||
|
||||
```yaml
|
||||
header: "Font"
|
||||
question: "Which font style?"
|
||||
multiSelect: false
|
||||
options:
|
||||
- label: "[auto-recommended font] (Recommended)"
|
||||
description: "[reason based on content signals]"
|
||||
- label: "clean"
|
||||
description: "Modern geometric sans-serif - tech, professional"
|
||||
- label: "handwritten"
|
||||
description: "Warm hand-lettered - personal, friendly"
|
||||
- label: "serif"
|
||||
description: "Classic elegant - editorial, luxury"
|
||||
- label: "display"
|
||||
description: "Bold decorative - announcements, entertainment"
|
||||
```
|
||||
|
||||
### Q5: Other Settings (skip if all remaining dimensions already specified)
|
||||
|
||||
Combine remaining settings into one question. Include: Output Dir (if no preference + file path input), Text, Mood, Aspect. Show auto-selected values as recommended option. User can accept all or type adjustments via "Other".
|
||||
|
||||
|
||||
@@ -3,6 +3,19 @@
|
||||
Save to `prompts/cover.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
type: cover
|
||||
palette: [confirmed palette]
|
||||
rendering: [confirmed rendering]
|
||||
references:
|
||||
- ref_id: 01
|
||||
filename: refs/ref-01-{slug}.{ext}
|
||||
usage: direct | style | palette
|
||||
- ref_id: 02
|
||||
filename: refs/ref-02-{slug}.{ext}
|
||||
usage: direct | style | palette
|
||||
---
|
||||
|
||||
# Content Context
|
||||
Article title: [full original title from source]
|
||||
Content summary: [2-3 sentence summary of key points and themes]
|
||||
@@ -13,6 +26,7 @@ Cover theme: [2-3 words visual interpretation]
|
||||
Type: [confirmed type]
|
||||
Palette: [confirmed palette]
|
||||
Rendering: [confirmed rendering]
|
||||
Font: [confirmed font]
|
||||
Text level: [confirmed text level]
|
||||
Mood: [confirmed mood]
|
||||
Aspect ratio: [confirmed ratio]
|
||||
@@ -21,9 +35,9 @@ Language: [confirmed language]
|
||||
# Text Elements
|
||||
[Based on text level:]
|
||||
- none: "No text elements"
|
||||
- title-only: "Title: [max 8 chars headline]"
|
||||
- title-subtitle: "Title: [headline] / Subtitle: [max 15 chars context]"
|
||||
- text-rich: "Title: [headline] / Subtitle: [context] / Tags: [2-4 keywords]"
|
||||
- title-only: "Title: [exact title from source or user]"
|
||||
- title-subtitle: "Title: [title] / Subtitle: [context]"
|
||||
- text-rich: "Title: [title] / Subtitle: [context] / Tags: [2-4 keywords]"
|
||||
|
||||
# Mood Application
|
||||
[Based on mood level:]
|
||||
@@ -31,6 +45,13 @@ Language: [confirmed language]
|
||||
- balanced: "Use medium contrast, normal saturation, balanced visual weight"
|
||||
- bold: "Use high contrast, vivid saturated colors, heavy visual weight, dynamic energy"
|
||||
|
||||
# Font Application
|
||||
[Based on font style:]
|
||||
- clean: "Use clean geometric sans-serif typography. Modern, minimal letterforms."
|
||||
- handwritten: "Use warm hand-lettered typography with organic brush strokes. Friendly, personal feel."
|
||||
- serif: "Use elegant serif typography with refined letterforms. Classic, editorial character."
|
||||
- display: "Use bold decorative display typography. Heavy, expressive headlines."
|
||||
|
||||
# Composition
|
||||
Type composition:
|
||||
- [Type-specific layout and structure]
|
||||
@@ -46,14 +67,37 @@ Type notes: [key characteristics from type definition]
|
||||
Palette notes: [key characteristics from palette definition]
|
||||
|
||||
[Watermark section if enabled]
|
||||
|
||||
[Reference images section if provided — REQUIRED, see below]
|
||||
```
|
||||
|
||||
## Reference-Driven Design ⚠️ HIGH PRIORITY
|
||||
|
||||
When reference images are provided, they are the **primary visual input** and MUST strongly influence the output. The cover should look like it belongs to the same visual family as the references.
|
||||
|
||||
**Passing `--ref` alone is NOT enough.** Image generation models often ignore reference images unless the prompt text explicitly describes what to reproduce. Always combine `--ref` with detailed textual instructions.
|
||||
|
||||
## Content-Driven Design
|
||||
|
||||
- Article title and summary inform the visual metaphor choice
|
||||
- Keywords guide decorative elements and symbols
|
||||
- The skill controls visual style; the content drives meaning
|
||||
|
||||
## Visual Element Selection
|
||||
|
||||
Match content themes to icon vocabulary:
|
||||
|
||||
| Content Theme | Suggested Elements |
|
||||
|---------------|-------------------|
|
||||
| Programming/Dev | Code window, terminal, API brackets, gear |
|
||||
| AI/ML | Brain, neural network, robot, circuit |
|
||||
| Growth/Business | Chart, rocket, plant, mountain, arrow |
|
||||
| Security | Lock, shield, key, fingerprint |
|
||||
| Communication | Speech bubble, megaphone, mail, handshake |
|
||||
| Tools/Methods | Wrench, checklist, pencil, puzzle |
|
||||
|
||||
Full library: [../visual-elements.md](../visual-elements.md)
|
||||
|
||||
## Type-Specific Composition
|
||||
|
||||
| Type | Composition Guidelines |
|
||||
@@ -68,8 +112,8 @@ Palette notes: [key characteristics from palette definition]
|
||||
## Title Guidelines
|
||||
|
||||
When text level includes title:
|
||||
- Max 8 characters, punchy headline
|
||||
- Use engagement hooks: numbers ("3个陷阱"), questions ("Why X?"), contrasts ("A vs B"), pain points ("别再X了")
|
||||
- **Source**: Use the exact title provided by user, or extract from source content
|
||||
- **Do NOT invent titles**: Stay faithful to the original
|
||||
- Match confirmed language
|
||||
|
||||
## Watermark Application
|
||||
@@ -82,3 +126,122 @@ The watermark should be legible but not distracting from the main content.
|
||||
```
|
||||
|
||||
Reference: `config/watermark-guide.md`
|
||||
|
||||
## Reference Image Handling
|
||||
|
||||
When user provides reference images (`--ref` or pasted images):
|
||||
|
||||
### ⚠️ CRITICAL - Frontmatter References
|
||||
|
||||
**MUST add `references` field in YAML frontmatter** when reference files are saved to `refs/`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: cover
|
||||
palette: warm
|
||||
rendering: flat-vector
|
||||
references:
|
||||
- ref_id: 01
|
||||
filename: refs/ref-01-podcast-thumbnail.jpg
|
||||
usage: style
|
||||
---
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `ref_id` | Sequential number (01, 02, ...) |
|
||||
| `filename` | Relative path from prompt file's parent directory |
|
||||
| `usage` | `direct` / `style` / `palette` |
|
||||
|
||||
**Omit `references` field entirely** if no reference files saved (style extracted verbally only).
|
||||
|
||||
### When to Include References in Frontmatter
|
||||
|
||||
| Situation | Frontmatter Action | Generation Action |
|
||||
|-----------|-------------------|-------------------|
|
||||
| Reference file saved to `refs/` | Add to `references` list ✓ | Pass via `--ref` parameter |
|
||||
| Style extracted verbally (no file) | Omit `references` field | Describe in prompt body only |
|
||||
| File path in frontmatter but doesn't exist | ERROR - fix or remove | Generation will fail |
|
||||
|
||||
**Before writing prompt with references, verify**: `test -f refs/ref-NN-{slug}.{ext}`
|
||||
|
||||
### Reference Usage Types
|
||||
|
||||
| Usage | When to Use | Generation Action |
|
||||
|-------|-------------|-------------------|
|
||||
| `direct` | Reference matches desired output closely | Pass to `--ref` parameter |
|
||||
| `style` | Extract visual style characteristics only | Describe style in prompt text |
|
||||
| `palette` | Extract color palette only | Include colors in prompt |
|
||||
|
||||
### Step 1: Analyze References
|
||||
|
||||
For each reference image, extract:
|
||||
- **Style**: Rendering technique, line quality, texture
|
||||
- **Composition**: Layout, visual hierarchy, focal points
|
||||
- **Color mood**: Palette characteristics (without specific colors)
|
||||
- **Elements**: Key visual elements and symbols used
|
||||
|
||||
### Step 2: Embed in Prompt ⚠️ CRITICAL
|
||||
|
||||
**Passing `--ref` alone is NOT enough.** Image generation models frequently ignore reference images unless the prompt text explicitly and forcefully describes what to reproduce. You MUST always write detailed textual instructions regardless of whether `--ref` is used.
|
||||
|
||||
**If file saved (with or without `--ref` support)**:
|
||||
- Pass ref images via `--ref` parameter if skill supports it
|
||||
- **ALWAYS** add a detailed mandatory section in the prompt body:
|
||||
|
||||
```
|
||||
# Reference Style — MUST INCORPORATE
|
||||
|
||||
CRITICAL: The generated cover MUST visually reference the provided images. The cover must feel like it belongs to the same visual family.
|
||||
|
||||
## From Ref 1 ([filename]) — REQUIRED elements:
|
||||
- [Brand element]: [Specific description of logo/wordmark treatment, e.g., "The logo uses vertical parallel lines (|||) for the letter 'm'. Reproduce this exact treatment."]
|
||||
- [Signature pattern]: [Specific description, e.g., "Woven intersecting curves forming a diamond/lozenge grid pattern. This MUST appear prominently as a banner, border, or background section."]
|
||||
- [Colors]: [Exact hex values, e.g., "Dark teal #2D4A3E background, cream #F5F0E0 text"]
|
||||
- [Typography]: [Specific treatment, e.g., "Uppercase text with wide letter-spacing"]
|
||||
- [Layout element]: [Specific spatial element, e.g., "Bottom banner strip in dark color"]
|
||||
|
||||
## From Ref 2 ([filename]) — REQUIRED elements:
|
||||
[Same detailed breakdown]
|
||||
|
||||
## Integration approach:
|
||||
[Specific layout instruction describing how reference elements combine with the cover content, e.g., "Use a SPLIT LAYOUT: main illustration area (warm cream background) occupies ~65% of the image, while a dark teal BANNER STRIP (with the woven line pattern from Ref 2) runs along the bottom ~35%, containing branding elements from Ref 1."]
|
||||
```
|
||||
|
||||
**Key rules**:
|
||||
- Each visual element gets its own bullet with "MUST" or "REQUIRED"
|
||||
- Descriptions must be **specific enough to reproduce** — not vague ("clean style")
|
||||
- The integration approach must describe **exact spatial arrangement**
|
||||
- After generation, verify reference elements are visible; if not, strengthen and regenerate
|
||||
|
||||
**If style/palette extracted verbally (NO file saved)**:
|
||||
- DO NOT add references metadata to prompt
|
||||
- Append extracted info directly to prompt body using the same MUST INCORPORATE format above:
|
||||
|
||||
```
|
||||
# Reference Style — MUST INCORPORATE (extracted from visual analysis)
|
||||
|
||||
CRITICAL: Apply these specific visual elements extracted from the reference images.
|
||||
|
||||
## REQUIRED elements:
|
||||
- [Same detailed bullet format as above]
|
||||
|
||||
## Integration approach:
|
||||
[Same spatial layout instruction]
|
||||
```
|
||||
|
||||
### Reference Analysis Template
|
||||
|
||||
Use this format when analyzing reference images. Extract **specific, concrete, reproducible** details — not vague summaries.
|
||||
|
||||
| Aspect | Analysis Points | Good Example | Bad Example |
|
||||
|--------|-----------------|--------------|-------------|
|
||||
| **Brand elements** | Logos, wordmarks, distinctive typography | "Logo 'm' formed by 3 vertical lines" | "Has a logo" |
|
||||
| **Signature patterns** | Unique motifs, textures, geometric patterns | "Woven curves forming diamond grid" | "Has patterns" |
|
||||
| **Colors** | Exact hex values or close approximations | "#2D4A3E dark teal, #F5F0E0 cream" | "Dark and light" |
|
||||
| **Layout** | Spatial zones, banner placement, proportions | "Bottom 30% is dark banner with branding" | "Has a banner" |
|
||||
| **Typography** | Font style, weight, case, spacing, position | "Uppercase, wide letter-spacing, right-aligned" | "Has text" |
|
||||
| **Rendering** | Line quality, texture, depth treatment | "Topographic contour lines as background texture" | "Clean style" |
|
||||
| **Elements** | Icon vocabulary, decorative motifs | "Geometric intersecting line ornaments at corners" | "Has decorations" |
|
||||
|
||||
**Output**: Each extracted element should be written as a **copy-pasteable prompt instruction** prefixed with "MUST" or "REQUIRED".
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Reference Image Handling
|
||||
|
||||
Guide for processing user-provided reference images in cover generation.
|
||||
|
||||
## Input Detection
|
||||
|
||||
| Input Type | Action |
|
||||
|------------|--------|
|
||||
| Image file path provided | Copy to `refs/` → can use `--ref` |
|
||||
| Image in conversation (no path) | **ASK user for file path** with AskUserQuestion |
|
||||
| User can't provide path | Extract style/palette verbally → append to prompt (NO frontmatter references) |
|
||||
|
||||
**CRITICAL**: Only add `references` to prompt frontmatter if files are ACTUALLY SAVED to `refs/` directory.
|
||||
|
||||
## File Saving
|
||||
|
||||
**If user provides file path**:
|
||||
1. Copy to `refs/ref-NN-{slug}.{ext}` (NN = 01, 02, ...)
|
||||
2. Create description: `refs/ref-NN-{slug}.md`
|
||||
3. Verify files exist before proceeding
|
||||
|
||||
**Description File Format**:
|
||||
```yaml
|
||||
---
|
||||
ref_id: NN
|
||||
filename: ref-NN-{slug}.{ext}
|
||||
usage: direct | style | palette
|
||||
---
|
||||
[User's description or auto-generated description]
|
||||
```
|
||||
|
||||
| Usage | When to Use |
|
||||
|-------|-------------|
|
||||
| `direct` | Reference matches desired output closely |
|
||||
| `style` | Extract visual style characteristics only |
|
||||
| `palette` | Extract color scheme only |
|
||||
|
||||
## Verbal Extraction (No File)
|
||||
|
||||
When user can't provide file path:
|
||||
1. Analyze image visually, extract: colors, style, composition
|
||||
2. Create `refs/extracted-style.md` with extracted info
|
||||
3. DO NOT add `references` to prompt frontmatter
|
||||
4. Append extracted style/colors directly to prompt text
|
||||
|
||||
## Deep Analysis ⚠️ CRITICAL
|
||||
|
||||
References are high-priority inputs. Extract **specific, concrete, reproducible** elements:
|
||||
|
||||
| Analysis | Description | Example (good vs bad) |
|
||||
|----------|-------------|----------------------|
|
||||
| **Brand elements** | Logos, wordmarks, specific typography | Good: "Logo uses vertical parallel lines for 'm'" / Bad: "Has a logo" |
|
||||
| **Signature patterns** | Unique decorative motifs, textures | Good: "Woven intersecting curves forming diamond grid" / Bad: "Has patterns" |
|
||||
| **Color palette** | Exact hex values for key colors | Good: "#2D4A3E dark teal, #F5F0E0 cream" / Bad: "Dark and light colors" |
|
||||
| **Layout structure** | Specific spatial arrangement | Good: "Bottom 30% dark banner with branding" / Bad: "Has a banner" |
|
||||
| **Typography** | Font style, weight, spacing, case | Good: "Uppercase, wide letter-spacing" / Bad: "Has text" |
|
||||
| **Content/subject** | What the reference depicts | Factual description |
|
||||
| **Usage recommendation** | `direct` / `style` / `palette` | Based on analysis |
|
||||
|
||||
**Output format**: List each element as bullet that can be copy-pasted into prompt as mandatory instruction.
|
||||
|
||||
## Verification Output
|
||||
|
||||
**For saved files**:
|
||||
```
|
||||
Reference Images Saved:
|
||||
- ref-01-{slug}.png ✓ (can use --ref)
|
||||
- ref-02-{slug}.png ✓ (can use --ref)
|
||||
```
|
||||
|
||||
**For extracted style**:
|
||||
```
|
||||
Reference Style Extracted (no file):
|
||||
- Colors: #E8756D coral, #7ECFC0 mint...
|
||||
- Style: minimal flat vector, clean lines...
|
||||
→ Will append to prompt text (not --ref)
|
||||
```
|
||||
|
||||
## Priority Rules
|
||||
|
||||
When user provides references, they are **HIGH PRIORITY**:
|
||||
|
||||
- **References override defaults**: If reference conflicts with preferred palette/rendering, reference takes precedence
|
||||
- **Concrete > abstract**: Extract specific elements — not vague "clean style"
|
||||
- **Mandatory language**: Use "MUST", "REQUIRED" in prompt for reference elements
|
||||
- **Visible in output**: Verify elements are present after generation; strengthen prompt if not
|
||||
@@ -76,7 +76,7 @@ test -f "$HOME/.baoyu-skills/baoyu-danger-gemini-web/EXTEND.md" && echo "user"
|
||||
```bash
|
||||
# Text generation
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "Your prompt"
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Your prompt" --model gemini-2.5-pro
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Your prompt" --model gemini-3-flash
|
||||
|
||||
# Image generation
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cute cat" --image cat.png
|
||||
@@ -100,7 +100,7 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello" --json
|
||||
|--------|-------------|
|
||||
| `--prompt`, `-p` | Prompt text |
|
||||
| `--promptfiles` | Read prompt from files (concatenated) |
|
||||
| `--model`, `-m` | Model: gemini-3-pro (default), gemini-2.5-pro, gemini-2.5-flash |
|
||||
| `--model`, `-m` | Model: gemini-3-pro (default), gemini-3-flash, gemini-3-flash-thinking, gemini-3.1-pro-preview |
|
||||
| `--image [path]` | Generate image (default: generated.png) |
|
||||
| `--reference`, `--ref` | Reference images for vision input |
|
||||
| `--sessionId` | Session ID for multi-turn conversation |
|
||||
@@ -114,9 +114,10 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello" --json
|
||||
|
||||
| Model | Description |
|
||||
|-------|-------------|
|
||||
| `gemini-3-pro` | Default, latest |
|
||||
| `gemini-2.5-pro` | Previous pro |
|
||||
| `gemini-2.5-flash` | Fast, lightweight |
|
||||
| `gemini-3-pro` | Default, latest 3.0 Pro |
|
||||
| `gemini-3-flash` | Fast, lightweight 3.0 Flash |
|
||||
| `gemini-3-flash-thinking` | 3.0 Flash with thinking |
|
||||
| `gemini-3.1-pro-preview` | 3.1 Pro preview (empty header, auto-routed) |
|
||||
|
||||
## Authentication
|
||||
|
||||
|
||||
@@ -47,17 +47,22 @@ export class Model {
|
||||
static readonly UNSPECIFIED = new Model('unspecified', {}, false);
|
||||
static readonly G_3_0_PRO = new Model(
|
||||
'gemini-3.0-pro',
|
||||
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4]]' },
|
||||
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4],null,null,1]' },
|
||||
false,
|
||||
);
|
||||
static readonly G_2_5_PRO = new Model(
|
||||
'gemini-2.5-pro',
|
||||
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"4af6c7f5da75d65d",null,null,0,[4]]' },
|
||||
static readonly G_3_0_FLASH = new Model(
|
||||
'gemini-3.0-flash',
|
||||
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"fbb127bbb056c959",null,null,0,[4],null,null,1]' },
|
||||
false,
|
||||
);
|
||||
static readonly G_2_5_FLASH = new Model(
|
||||
'gemini-2.5-flash',
|
||||
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"9ec249fc9ad08861",null,null,0,[4]]' },
|
||||
static readonly G_3_0_FLASH_THINKING = new Model(
|
||||
'gemini-3.0-flash-thinking',
|
||||
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"5bf011840784117a",null,null,0,[4],null,null,1]' },
|
||||
false,
|
||||
);
|
||||
static readonly G_3_1_PRO_PREVIEW = new Model(
|
||||
'gemini-3.1-pro-preview',
|
||||
{},
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -68,12 +73,12 @@ export class Model {
|
||||
) {}
|
||||
|
||||
static from_name(name: string): Model {
|
||||
for (const model of [Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_2_5_PRO, Model.G_2_5_FLASH]) {
|
||||
for (const model of [Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_3_0_FLASH, Model.G_3_0_FLASH_THINKING, Model.G_3_1_PRO_PREVIEW]) {
|
||||
if (model.model_name === name) return model;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unknown model name: ${name}. Available models: ${[Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_2_5_PRO, Model.G_2_5_FLASH]
|
||||
`Unknown model name: ${name}. Available models: ${[Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_3_0_FLASH, Model.G_3_0_FLASH_THINKING, Model.G_3_1_PRO_PREVIEW]
|
||||
.map((m) => m.model_name)
|
||||
.join(', ')}`,
|
||||
);
|
||||
|
||||
@@ -73,7 +73,7 @@ Multi-turn conversation (agent generates unique sessionId):
|
||||
Options:
|
||||
-p, --prompt <text> Prompt text
|
||||
--promptfiles <files...> Read prompt from one or more files (concatenated in order)
|
||||
-m, --model <id> gemini-3-pro | gemini-2.5-pro | gemini-2.5-flash (default: gemini-3-pro)
|
||||
-m, --model <id> gemini-3-pro | gemini-3-flash | gemini-3-flash-thinking | gemini-3.1-pro-preview (default: gemini-3-pro)
|
||||
--json Output JSON
|
||||
--image [path] Generate an image and save it (default: ./generated.png)
|
||||
--reference <files...> Reference images for vision input
|
||||
@@ -227,8 +227,11 @@ function resolveModel(id: string): Model {
|
||||
const k = id.trim();
|
||||
if (k === 'gemini-3-pro') return Model.G_3_0_PRO;
|
||||
if (k === 'gemini-3.0-pro') return Model.G_3_0_PRO;
|
||||
if (k === 'gemini-2.5-pro') return Model.G_2_5_PRO;
|
||||
if (k === 'gemini-2.5-flash') return Model.G_2_5_FLASH;
|
||||
if (k === 'gemini-3-flash') return Model.G_3_0_FLASH;
|
||||
if (k === 'gemini-3.0-flash') return Model.G_3_0_FLASH;
|
||||
if (k === 'gemini-3-flash-thinking') return Model.G_3_0_FLASH_THINKING;
|
||||
if (k === 'gemini-3.0-flash-thinking') return Model.G_3_0_FLASH_THINKING;
|
||||
if (k === 'gemini-3.1-pro-preview') return Model.G_3_1_PRO_PREVIEW;
|
||||
return Model.from_name(k);
|
||||
}
|
||||
|
||||
|
||||
@@ -92,16 +92,52 @@ test -f "$HOME/.baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md" && echo "user
|
||||
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||
│ Found │ Read, parse, apply settings │
|
||||
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||
│ Not found │ Use defaults │
|
||||
│ Not found │ **MUST** run first-time setup (see below) — do NOT silently create defaults │
|
||||
└───────────┴───────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
**EXTEND.md Supports**: Default output directory | Output format preferences
|
||||
**EXTEND.md Supports**: Download media by default | Default output directory
|
||||
|
||||
### First-Time Setup (BLOCKING)
|
||||
|
||||
**CRITICAL**: When EXTEND.md is not found, you **MUST use `AskUserQuestion`** to ask the user for their preferences before creating EXTEND.md. **NEVER** create EXTEND.md with defaults without asking. This is a **BLOCKING** operation — do NOT proceed with any conversion until setup is complete.
|
||||
|
||||
Use `AskUserQuestion` with ALL questions in ONE call:
|
||||
|
||||
**Question 1** — header: "Media", question: "How to handle images and videos in tweets?"
|
||||
- "Ask each time (Recommended)" — After saving markdown, ask whether to download media
|
||||
- "Always download" — Always download media to local imgs/ and videos/ directories
|
||||
- "Never download" — Keep original remote URLs in markdown
|
||||
|
||||
**Question 2** — header: "Output", question: "Default output directory?"
|
||||
- "x-to-markdown (Recommended)" — Save to ./x-to-markdown/{username}/{tweet-id}.md
|
||||
- (User may choose "Other" to type a custom path)
|
||||
|
||||
**Question 3** — header: "Save", question: "Where to save preferences?"
|
||||
- "User (Recommended)" — ~/.baoyu-skills/ (all projects)
|
||||
- "Project" — .baoyu-skills/ (this project only)
|
||||
|
||||
After user answers, create EXTEND.md at the chosen location, confirm "Preferences saved to [path]", then continue.
|
||||
|
||||
Full reference: [references/config/first-time-setup.md](references/config/first-time-setup.md)
|
||||
|
||||
### Supported Keys
|
||||
|
||||
| Key | Default | Values | Description |
|
||||
|-----|---------|--------|-------------|
|
||||
| `download_media` | `ask` | `ask` / `1` / `0` | `ask` = prompt each time, `1` = always download, `0` = never |
|
||||
| `default_output_dir` | empty | path or empty | Default output directory (empty = `./x-to-markdown/`) |
|
||||
|
||||
**Value priority**:
|
||||
1. CLI arguments (`--download-media`, `-o`)
|
||||
2. EXTEND.md
|
||||
3. Skill defaults
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts <url>
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts <url> -o output.md
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --download-media
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --json
|
||||
```
|
||||
|
||||
@@ -112,6 +148,7 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --json
|
||||
| `<url>` | Tweet or article URL |
|
||||
| `-o <path>` | Output path |
|
||||
| `--json` | JSON output |
|
||||
| `--download-media` | Download image/video assets to local `imgs/` and `videos/`, and rewrite markdown links to local relative paths |
|
||||
| `--login` | Refresh cookies only |
|
||||
|
||||
## Supported URLs
|
||||
@@ -124,15 +161,42 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --json
|
||||
|
||||
```markdown
|
||||
---
|
||||
url: https://x.com/user/status/123
|
||||
url: "https://x.com/user/status/123"
|
||||
author: "Name (@user)"
|
||||
tweet_count: 3
|
||||
tweetCount: 3
|
||||
coverImage: "https://pbs.twimg.com/media/example.jpg"
|
||||
---
|
||||
|
||||
Content...
|
||||
```
|
||||
|
||||
**File structure**: `x-to-markdown/{username}/{tweet-id}.md`
|
||||
**File structure**: `x-to-markdown/{username}/{tweet-id}/{content-slug}.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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -2,10 +2,12 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
import process from "node:process";
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
import { fetchXArticle } from "./graphql.js";
|
||||
import { formatArticleMarkdown } from "./markdown.js";
|
||||
import { localizeMarkdownMedia, type LocalizeMarkdownMediaResult } from "./media-localizer.js";
|
||||
import { resolveReferencedTweetsFromArticle } from "./referenced-tweets.js";
|
||||
import { hasRequiredXCookies, loadXCookies, refreshXCookies } from "./cookies.js";
|
||||
import { resolveXToMarkdownConsentPath } from "./paths.js";
|
||||
import { tweetToMarkdown } from "./tweet-to-markdown.js";
|
||||
@@ -15,6 +17,7 @@ type CliArgs = {
|
||||
output: string | null;
|
||||
json: boolean;
|
||||
login: boolean;
|
||||
downloadMedia: boolean;
|
||||
help: boolean;
|
||||
};
|
||||
|
||||
@@ -38,6 +41,7 @@ Usage:
|
||||
Options:
|
||||
--output <path>, -o Output path (file or dir). Default: ./x-to-markdown/<slug>/
|
||||
--json Output as JSON
|
||||
--download-media Download images/videos to local ./imgs and ./videos next to markdown
|
||||
--login Refresh cookies only, then exit
|
||||
--help, -h Show help
|
||||
|
||||
@@ -45,6 +49,7 @@ Examples:
|
||||
${cmd} https://x.com/username/status/1234567890
|
||||
${cmd} https://x.com/i/article/1234567890 -o ./article.md
|
||||
${cmd} https://x.com/username/status/1234567890 -o ./out/
|
||||
${cmd} https://x.com/username/status/1234567890 --download-media
|
||||
${cmd} https://x.com/username/status/1234567890 --json | jq -r '.markdownPath'
|
||||
${cmd} --login
|
||||
`);
|
||||
@@ -57,6 +62,7 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
output: null,
|
||||
json: false,
|
||||
login: false,
|
||||
downloadMedia: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
@@ -80,6 +86,11 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === "--download-media") {
|
||||
out.downloadMedia = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === "--url") {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error("Missing value for --url");
|
||||
@@ -172,36 +183,160 @@ function sanitizeSlug(input: string): string {
|
||||
.slice(0, 120);
|
||||
}
|
||||
|
||||
function formatBackupTimestamp(date: Date = new Date()): string {
|
||||
const pad2 = (n: number) => String(n).padStart(2, "0");
|
||||
return `${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}-${pad2(date.getHours())}${pad2(
|
||||
date.getMinutes()
|
||||
)}${pad2(date.getSeconds())}`;
|
||||
function extractContentSlug(markdown: string): string {
|
||||
const headingMatch = markdown.match(/^#\s+(.+)$/m);
|
||||
if (headingMatch?.[1]) {
|
||||
return sanitizeSlug(headingMatch[1].slice(0, 60)).toLowerCase();
|
||||
}
|
||||
const lines = markdown.split("\n");
|
||||
let inFrontmatter = false;
|
||||
for (const line of lines) {
|
||||
if (line === "---") {
|
||||
inFrontmatter = !inFrontmatter;
|
||||
continue;
|
||||
}
|
||||
if (inFrontmatter) continue;
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) {
|
||||
return sanitizeSlug(trimmed.slice(0, 60)).toLowerCase();
|
||||
}
|
||||
}
|
||||
return "untitled";
|
||||
}
|
||||
|
||||
async function backupDirIfExists(dir: string, log: (message: string) => void): Promise<void> {
|
||||
function resolveSlugAndId(normalizedUrl: string, kind: "tweet" | "article"): { slug: string; idPart: string } {
|
||||
const articleId = kind === "article" ? parseArticleId(normalizedUrl) : null;
|
||||
const tweetId = kind === "tweet" ? parseTweetId(normalizedUrl) : null;
|
||||
const username = kind === "tweet" ? parseTweetUsername(normalizedUrl) : null;
|
||||
|
||||
const idPart = articleId ?? tweetId ?? String(Date.now());
|
||||
const userSlug = username ? sanitizeSlug(username) : null;
|
||||
const slug = userSlug ?? idPart;
|
||||
return { slug, idPart };
|
||||
}
|
||||
|
||||
function extractFrontmatterUrls(markdown: string): string[] {
|
||||
const match = markdown.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!match?.[1]) return [];
|
||||
|
||||
const lines = match[1].split("\n");
|
||||
const urls: string[] = [];
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^(url|requestedUrl):\s*["']([^"']+)["']\s*$/);
|
||||
if (m?.[2]) {
|
||||
urls.push(m[2]);
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
function frontmatterMatchesTarget(
|
||||
markdown: string,
|
||||
normalizedUrl: string,
|
||||
kind: "tweet" | "article"
|
||||
): boolean {
|
||||
const urls = extractFrontmatterUrls(markdown);
|
||||
if (urls.length === 0) return false;
|
||||
|
||||
const targetId = kind === "article" ? parseArticleId(normalizedUrl) : parseTweetId(normalizedUrl);
|
||||
if (!targetId) return false;
|
||||
|
||||
for (const url of urls) {
|
||||
const candidateId = kind === "article" ? parseArticleId(url) : parseTweetId(url);
|
||||
if (candidateId && candidateId === targetId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function listMarkdownFiles(dirPath: string): string[] {
|
||||
try {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
const stat = fs.statSync(dir);
|
||||
if (!stat.isDirectory()) return;
|
||||
const backup = `${dir}-backup-${formatBackupTimestamp()}`;
|
||||
await rename(dir, backup);
|
||||
log(`[x-to-markdown] Existing directory moved to: ${backup}`);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to backup existing directory (${dir}): ${error instanceof Error ? error.message : String(error ?? "")}`
|
||||
);
|
||||
return fs
|
||||
.readdirSync(dirPath)
|
||||
.filter((name) => name.toLowerCase().endsWith(".md"))
|
||||
.map((name) => path.join(dirPath, name))
|
||||
.sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDefaultOutputDir(slug: string): string {
|
||||
return path.resolve(process.cwd(), "x-to-markdown", slug);
|
||||
function resolveExistingMarkdownPath(
|
||||
normalizedUrl: string,
|
||||
kind: "tweet" | "article",
|
||||
argsOutput: string | null
|
||||
): string | null {
|
||||
const { slug, idPart } = resolveSlugAndId(normalizedUrl, kind);
|
||||
const candidateDirs = new Set<string>();
|
||||
const candidateFiles = new Set<string>();
|
||||
|
||||
if (argsOutput) {
|
||||
const resolved = path.resolve(argsOutput);
|
||||
const looksDir = argsOutput.endsWith("/") || argsOutput.endsWith("\\");
|
||||
try {
|
||||
if (fs.existsSync(resolved)) {
|
||||
const stat = fs.statSync(resolved);
|
||||
if (stat.isFile()) {
|
||||
candidateFiles.add(resolved);
|
||||
} else if (stat.isDirectory()) {
|
||||
candidateDirs.add(path.join(resolved, slug, idPart));
|
||||
candidateDirs.add(resolved);
|
||||
}
|
||||
} else if (looksDir) {
|
||||
candidateDirs.add(path.join(resolved, slug, idPart));
|
||||
}
|
||||
} catch {
|
||||
// ignore and continue
|
||||
}
|
||||
} else {
|
||||
candidateDirs.add(path.resolve(process.cwd(), "x-to-markdown", slug, idPart));
|
||||
}
|
||||
|
||||
for (const filePath of candidateFiles) {
|
||||
if (!filePath.toLowerCase().endsWith(".md")) continue;
|
||||
try {
|
||||
const markdown = fs.readFileSync(filePath, "utf8");
|
||||
if (frontmatterMatchesTarget(markdown, normalizedUrl, kind)) {
|
||||
return filePath;
|
||||
}
|
||||
} catch {
|
||||
// ignore and continue
|
||||
}
|
||||
}
|
||||
|
||||
for (const dirPath of candidateDirs) {
|
||||
if (!fs.existsSync(dirPath)) continue;
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(dirPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!stat.isDirectory()) continue;
|
||||
|
||||
const markdownFiles = listMarkdownFiles(dirPath);
|
||||
for (const filePath of markdownFiles) {
|
||||
try {
|
||||
const markdown = fs.readFileSync(filePath, "utf8");
|
||||
if (frontmatterMatchesTarget(markdown, normalizedUrl, kind)) {
|
||||
return filePath;
|
||||
}
|
||||
} catch {
|
||||
// ignore and continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveOutputPath(
|
||||
normalizedUrl: string,
|
||||
kind: "tweet" | "article",
|
||||
argsOutput: string | null,
|
||||
contentSlug: string,
|
||||
log: (message: string) => void
|
||||
): Promise<{ outputDir: string; markdownPath: string; slug: string }> {
|
||||
const articleId = kind === "article" ? parseArticleId(normalizedUrl) : null;
|
||||
@@ -212,15 +347,14 @@ async function resolveOutputPath(
|
||||
const idPart = articleId ?? tweetId ?? String(Date.now());
|
||||
const slug = userSlug ?? idPart;
|
||||
|
||||
const defaultFileName = kind === "article" ? `${idPart}.md` : `${idPart}.md`;
|
||||
const defaultFileName = `${contentSlug}.md`;
|
||||
|
||||
if (argsOutput) {
|
||||
const wantsDir = argsOutput.endsWith("/") || argsOutput.endsWith("\\");
|
||||
const resolved = path.resolve(argsOutput);
|
||||
try {
|
||||
if (wantsDir || (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory())) {
|
||||
const outputDir = path.join(resolved, slug);
|
||||
await backupDirIfExists(outputDir, log);
|
||||
const outputDir = path.join(resolved, slug, idPart);
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };
|
||||
}
|
||||
@@ -233,8 +367,7 @@ async function resolveOutputPath(
|
||||
return { outputDir, markdownPath: resolved, slug };
|
||||
}
|
||||
|
||||
const outputDir = resolveDefaultOutputDir(slug);
|
||||
await backupDirIfExists(outputDir, log);
|
||||
const outputDir = path.resolve(process.cwd(), "x-to-markdown", slug, idPart);
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };
|
||||
}
|
||||
@@ -345,16 +478,18 @@ async function convertArticleToMarkdown(
|
||||
|
||||
log(`[x-to-markdown] Fetching article ${articleId}...`);
|
||||
const article = await fetchXArticle(articleId, cookieMap, false);
|
||||
const body = formatArticleMarkdown(article).trimEnd();
|
||||
const referencedTweets = await resolveReferencedTweetsFromArticle(article, cookieMap, { log });
|
||||
const { markdown: body, coverUrl } = formatArticleMarkdown(article, { referencedTweets });
|
||||
|
||||
const title = typeof (article as any)?.title === "string" ? String((article as any).title).trim() : "";
|
||||
const meta = formatMetaMarkdown({
|
||||
url: `https://x.com/i/article/${articleId}`,
|
||||
requested_url: inputUrl,
|
||||
requestedUrl: inputUrl,
|
||||
title: title || null,
|
||||
coverImage: coverUrl,
|
||||
});
|
||||
|
||||
return [meta, body].filter(Boolean).join("\n\n").trimEnd();
|
||||
return [meta, body.trimEnd()].filter(Boolean).join("\n\n").trimEnd();
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -383,13 +518,80 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
const kind = articleId ? ("article" as const) : ("tweet" as const);
|
||||
const { outputDir, markdownPath, slug } = await resolveOutputPath(normalizedUrl, kind, args.output, log);
|
||||
|
||||
const markdown =
|
||||
if (args.downloadMedia) {
|
||||
const existingMarkdownPath = resolveExistingMarkdownPath(normalizedUrl, kind, args.output);
|
||||
if (existingMarkdownPath) {
|
||||
log(`[x-to-markdown] Reusing existing markdown: ${existingMarkdownPath}`);
|
||||
const existingMarkdown = await readFile(existingMarkdownPath, "utf8");
|
||||
const mediaResult = await localizeMarkdownMedia(existingMarkdown, {
|
||||
markdownPath: existingMarkdownPath,
|
||||
log,
|
||||
});
|
||||
const didLocalize =
|
||||
mediaResult.downloadedImages > 0 ||
|
||||
mediaResult.downloadedVideos > 0 ||
|
||||
mediaResult.markdown !== existingMarkdown;
|
||||
|
||||
if (didLocalize) {
|
||||
await writeFile(existingMarkdownPath, mediaResult.markdown, "utf8");
|
||||
log(
|
||||
`[x-to-markdown] Media localized: images=${mediaResult.downloadedImages}, videos=${mediaResult.downloadedVideos}`
|
||||
);
|
||||
log(`[x-to-markdown] Saved: ${existingMarkdownPath}`);
|
||||
|
||||
const { slug } = resolveSlugAndId(normalizedUrl, kind);
|
||||
if (args.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
url: articleId ? `https://x.com/i/article/${articleId}` : normalizedUrl,
|
||||
requestedUrl: normalizedUrl,
|
||||
type: kind,
|
||||
slug,
|
||||
outputDir: path.dirname(existingMarkdownPath),
|
||||
markdownPath: existingMarkdownPath,
|
||||
downloadMedia: true,
|
||||
downloadedImages: mediaResult.downloadedImages,
|
||||
downloadedVideos: mediaResult.downloadedVideos,
|
||||
imageDir: mediaResult.imageDir,
|
||||
videoDir: mediaResult.videoDir,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} else {
|
||||
console.log(existingMarkdownPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
log("[x-to-markdown] Existing markdown already localized; rebuilding content to refresh placement.");
|
||||
}
|
||||
}
|
||||
|
||||
let markdown =
|
||||
kind === "article" && articleId
|
||||
? await convertArticleToMarkdown(normalizedUrl, articleId, log)
|
||||
: await tweetToMarkdown(normalizedUrl, { log });
|
||||
|
||||
const contentSlug = extractContentSlug(markdown);
|
||||
const { outputDir, markdownPath, slug } = await resolveOutputPath(normalizedUrl, kind, args.output, contentSlug, log);
|
||||
|
||||
let mediaResult: LocalizeMarkdownMediaResult | null = null;
|
||||
|
||||
if (args.downloadMedia) {
|
||||
mediaResult = await localizeMarkdownMedia(markdown, {
|
||||
markdownPath,
|
||||
log,
|
||||
});
|
||||
markdown = mediaResult.markdown;
|
||||
log(
|
||||
`[x-to-markdown] Media localized: images=${mediaResult.downloadedImages}, videos=${mediaResult.downloadedVideos}`
|
||||
);
|
||||
}
|
||||
|
||||
await writeFile(markdownPath, markdown, "utf8");
|
||||
log(`[x-to-markdown] Saved: ${markdownPath}`);
|
||||
|
||||
@@ -398,11 +600,16 @@ async function main(): Promise<void> {
|
||||
JSON.stringify(
|
||||
{
|
||||
url: articleId ? `https://x.com/i/article/${articleId}` : normalizedUrl,
|
||||
requested_url: normalizedUrl,
|
||||
requestedUrl: normalizedUrl,
|
||||
type: kind,
|
||||
slug,
|
||||
outputDir,
|
||||
markdownPath,
|
||||
downloadMedia: args.downloadMedia,
|
||||
downloadedImages: mediaResult?.downloadedImages ?? 0,
|
||||
downloadedVideos: mediaResult?.downloadedVideos ?? 0,
|
||||
imageDir: mediaResult?.imageDir ?? null,
|
||||
videoDir: mediaResult?.videoDir ?? null,
|
||||
},
|
||||
null,
|
||||
2
|
||||
|
||||
@@ -2,9 +2,22 @@ import type {
|
||||
ArticleBlock,
|
||||
ArticleContentState,
|
||||
ArticleEntity,
|
||||
ArticleEntityMapEntry,
|
||||
ArticleMediaInfo,
|
||||
} from "./types.js";
|
||||
|
||||
export type ReferencedTweetInfo = {
|
||||
id: string;
|
||||
url: string;
|
||||
authorName?: string;
|
||||
authorUsername?: string;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
export type FormatArticleOptions = {
|
||||
referencedTweets?: Map<string, ReferencedTweetInfo>;
|
||||
};
|
||||
|
||||
function coerceArticleEntity(value: unknown): ArticleEntity | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as ArticleEntity;
|
||||
@@ -29,6 +42,73 @@ function normalizeCaption(caption?: string): string {
|
||||
return trimmed.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function summarizeTweetText(text?: string): string {
|
||||
const trimmed = text?.trim();
|
||||
if (!trimmed) return "";
|
||||
const normalized = trimmed
|
||||
.split(/\r?\n+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
if (normalized.length <= 280) return normalized;
|
||||
return `${normalized.slice(0, 277)}...`;
|
||||
}
|
||||
|
||||
function buildTweetUrl(tweetId?: string, username?: string): string | null {
|
||||
if (!tweetId) return null;
|
||||
if (username) {
|
||||
return `https://x.com/${username}/status/${tweetId}`;
|
||||
}
|
||||
return `https://x.com/i/web/status/${tweetId}`;
|
||||
}
|
||||
|
||||
type EntityLookup = {
|
||||
byIndex: Map<number, ArticleEntityMapEntry>;
|
||||
byLogicalKey: Map<number, ArticleEntityMapEntry>;
|
||||
};
|
||||
|
||||
function buildEntityLookup(
|
||||
entityMap: ArticleContentState["entityMap"] | undefined
|
||||
): EntityLookup {
|
||||
const lookup: EntityLookup = {
|
||||
byIndex: new Map<number, ArticleEntityMapEntry>(),
|
||||
byLogicalKey: new Map<number, ArticleEntityMapEntry>(),
|
||||
};
|
||||
|
||||
if (!entityMap) return lookup;
|
||||
|
||||
for (const [idx, entry] of Object.entries(entityMap)) {
|
||||
const idxNum = Number(idx);
|
||||
if (Number.isFinite(idxNum)) {
|
||||
lookup.byIndex.set(idxNum, entry);
|
||||
}
|
||||
|
||||
const logicalKey = parseInt(entry?.key ?? "", 10);
|
||||
if (Number.isFinite(logicalKey) && !lookup.byLogicalKey.has(logicalKey)) {
|
||||
lookup.byLogicalKey.set(logicalKey, entry);
|
||||
}
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function resolveEntityEntry(
|
||||
entityKey: number | undefined,
|
||||
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||
lookup: EntityLookup
|
||||
): ArticleEntityMapEntry | undefined {
|
||||
if (entityKey === undefined) return undefined;
|
||||
|
||||
const byLogicalKey = lookup.byLogicalKey.get(entityKey);
|
||||
if (byLogicalKey) return byLogicalKey;
|
||||
|
||||
const byIndex = lookup.byIndex.get(entityKey);
|
||||
if (byIndex) return byIndex;
|
||||
|
||||
if (!entityMap) return undefined;
|
||||
return entityMap[String(entityKey)];
|
||||
}
|
||||
|
||||
function resolveMediaUrl(info?: ArticleMediaInfo): string | undefined {
|
||||
if (!info) return undefined;
|
||||
if (info.original_img_url) return info.original_img_url;
|
||||
@@ -79,11 +159,12 @@ function collectMediaUrls(
|
||||
function resolveEntityMediaLines(
|
||||
entityKey: number | undefined,
|
||||
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||
entityLookup: EntityLookup,
|
||||
mediaById: Map<string, string>,
|
||||
usedUrls: Set<string>
|
||||
): string[] {
|
||||
if (entityKey === undefined || !entityMap) return [];
|
||||
const entry = entityMap[String(entityKey)];
|
||||
if (entityKey === undefined) return [];
|
||||
const entry = resolveEntityEntry(entityKey, entityMap, entityLookup);
|
||||
const value = entry?.value;
|
||||
if (!value) return [];
|
||||
const type = value.type;
|
||||
@@ -117,11 +198,142 @@ function resolveEntityMediaLines(
|
||||
return lines;
|
||||
}
|
||||
|
||||
function resolveEntityTweetLines(
|
||||
entityKey: number | undefined,
|
||||
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||
entityLookup: EntityLookup,
|
||||
referencedTweets?: Map<string, ReferencedTweetInfo>
|
||||
): string[] {
|
||||
if (entityKey === undefined) return [];
|
||||
const entry = resolveEntityEntry(entityKey, entityMap, entityLookup);
|
||||
const value = entry?.value;
|
||||
if (!value || value.type !== "TWEET") return [];
|
||||
|
||||
const tweetId = typeof value.data?.tweetId === "string" ? value.data.tweetId : "";
|
||||
if (!tweetId) return [];
|
||||
|
||||
const referenced = referencedTweets?.get(tweetId);
|
||||
const url =
|
||||
referenced?.url ??
|
||||
buildTweetUrl(tweetId, referenced?.authorUsername) ??
|
||||
`https://x.com/i/web/status/${tweetId}`;
|
||||
|
||||
const authorText =
|
||||
referenced?.authorName && referenced?.authorUsername
|
||||
? `${referenced.authorName} (@${referenced.authorUsername})`
|
||||
: referenced?.authorUsername
|
||||
? `@${referenced.authorUsername}`
|
||||
: referenced?.authorName;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`> 引用推文${authorText ? `:${authorText}` : ""}`);
|
||||
|
||||
const summary = summarizeTweetText(referenced?.text);
|
||||
if (summary) {
|
||||
lines.push(`> ${summary}`);
|
||||
}
|
||||
|
||||
lines.push(`> ${url}`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildMediaLinkMap(
|
||||
entityMap: ArticleContentState["entityMap"] | undefined
|
||||
): Map<number, string> {
|
||||
const map = new Map<number, string>();
|
||||
if (!entityMap) return map;
|
||||
|
||||
const mediaEntries: { idx: number; key: number }[] = [];
|
||||
const linkEntries: { key: number; url: string }[] = [];
|
||||
|
||||
for (const [idx, entry] of Object.entries(entityMap)) {
|
||||
const value = entry?.value;
|
||||
if (!value) continue;
|
||||
const key = parseInt(entry?.key ?? "", 10);
|
||||
if (isNaN(key)) continue;
|
||||
|
||||
if (value.type === "MEDIA" || value.type === "IMAGE") {
|
||||
mediaEntries.push({ idx: Number(idx), key });
|
||||
} else if (value.type === "LINK" && typeof value.data?.url === "string") {
|
||||
linkEntries.push({ key, url: value.data.url });
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaEntries.length === 0 || linkEntries.length === 0) return map;
|
||||
|
||||
mediaEntries.sort((a, b) => a.key - b.key);
|
||||
linkEntries.sort((a, b) => a.key - b.key);
|
||||
|
||||
const pool = [...linkEntries];
|
||||
for (const media of mediaEntries) {
|
||||
if (pool.length === 0) break;
|
||||
let linkIdx = pool.findIndex((l) => l.key > media.key);
|
||||
if (linkIdx === -1) linkIdx = 0;
|
||||
const link = pool.splice(linkIdx, 1)[0]!;
|
||||
map.set(media.idx, link.url);
|
||||
map.set(media.key, link.url);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
function renderInlineLinks(
|
||||
text: string,
|
||||
entityRanges: Array<{ key?: number; offset?: number; length?: number }>,
|
||||
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||
entityLookup: EntityLookup,
|
||||
mediaLinkMap: Map<number, string>
|
||||
): string {
|
||||
if (!entityMap || entityRanges.length === 0) return text;
|
||||
|
||||
const valid = entityRanges.filter(
|
||||
(r) =>
|
||||
typeof r.key === "number" &&
|
||||
typeof r.offset === "number" &&
|
||||
typeof r.length === "number" &&
|
||||
r.length > 0
|
||||
);
|
||||
if (valid.length === 0) return text;
|
||||
|
||||
const sorted = [...valid].sort((a, b) => (b.offset ?? 0) - (a.offset ?? 0));
|
||||
|
||||
let result = text;
|
||||
for (const range of sorted) {
|
||||
const offset = range.offset!;
|
||||
const length = range.length!;
|
||||
const key = range.key!;
|
||||
|
||||
const entry = resolveEntityEntry(key, entityMap, entityLookup);
|
||||
const value = entry?.value;
|
||||
if (!value) continue;
|
||||
|
||||
let url: string | undefined;
|
||||
if (value.type === "LINK" && typeof value.data?.url === "string") {
|
||||
url = value.data.url;
|
||||
} else if (value.type === "MEDIA" || value.type === "IMAGE") {
|
||||
url = mediaLinkMap.get(key);
|
||||
}
|
||||
|
||||
if (!url) continue;
|
||||
|
||||
const linkText = result.slice(offset, offset + length);
|
||||
result =
|
||||
result.slice(0, offset) +
|
||||
`[${linkText}](${url})` +
|
||||
result.slice(offset + length);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function renderContentBlocks(
|
||||
blocks: ArticleBlock[],
|
||||
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||
entityLookup: EntityLookup,
|
||||
mediaById: Map<string, string>,
|
||||
usedUrls: Set<string>
|
||||
usedUrls: Set<string>,
|
||||
mediaLinkMap: Map<number, string>,
|
||||
referencedTweets?: Map<string, ReferencedTweetInfo>
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
let previousKind: "list" | "quote" | "heading" | "text" | "code" | "media" | null = null;
|
||||
@@ -150,14 +362,55 @@ function renderContentBlocks(
|
||||
const mediaLines: string[] = [];
|
||||
for (const range of ranges) {
|
||||
if (typeof range?.key !== "number") continue;
|
||||
mediaLines.push(...resolveEntityMediaLines(range.key, entityMap, mediaById, usedUrls));
|
||||
mediaLines.push(
|
||||
...resolveEntityMediaLines(range.key, entityMap, entityLookup, mediaById, usedUrls)
|
||||
);
|
||||
}
|
||||
return mediaLines;
|
||||
};
|
||||
|
||||
const collectTweetLines = (block: ArticleBlock): string[] => {
|
||||
const ranges = Array.isArray(block.entityRanges) ? block.entityRanges : [];
|
||||
const tweetLines: string[] = [];
|
||||
for (const range of ranges) {
|
||||
if (typeof range?.key !== "number") continue;
|
||||
tweetLines.push(
|
||||
...resolveEntityTweetLines(range.key, entityMap, entityLookup, referencedTweets)
|
||||
);
|
||||
}
|
||||
return tweetLines;
|
||||
};
|
||||
|
||||
const collectLinkLines = (block: ArticleBlock): string[] => {
|
||||
const ranges = Array.isArray(block.entityRanges) ? block.entityRanges : [];
|
||||
const linkLines: string[] = [];
|
||||
for (const range of ranges) {
|
||||
if (typeof range?.key !== "number") continue;
|
||||
const entry = resolveEntityEntry(range.key, entityMap, entityLookup);
|
||||
const value = entry?.value;
|
||||
if (value?.type !== "LINK") continue;
|
||||
const url = typeof value.data?.url === "string" ? value.data.url : "";
|
||||
if (url) {
|
||||
linkLines.push(url);
|
||||
}
|
||||
}
|
||||
return [...new Set(linkLines)];
|
||||
};
|
||||
|
||||
const pushTrailingMedia = (mediaLines: string[]) => {
|
||||
if (mediaLines.length > 0) {
|
||||
pushBlock(mediaLines, "media");
|
||||
}
|
||||
};
|
||||
|
||||
for (const block of blocks) {
|
||||
const type = typeof block?.type === "string" ? block.type : "unstyled";
|
||||
const text = typeof block?.text === "string" ? block.text : "";
|
||||
const rawText = typeof block?.text === "string" ? block.text : "";
|
||||
const ranges = Array.isArray(block.entityRanges) ? block.entityRanges : [];
|
||||
const text =
|
||||
type !== "atomic" && type !== "code-block"
|
||||
? renderInlineLinks(rawText, ranges, entityMap, entityLookup, mediaLinkMap)
|
||||
: rawText;
|
||||
|
||||
if (type === "code-block") {
|
||||
if (!inCodeBlock) {
|
||||
@@ -182,10 +435,22 @@ function renderContentBlocks(
|
||||
}
|
||||
listKind = null;
|
||||
orderedIndex = 0;
|
||||
|
||||
const tweetLines = collectTweetLines(block);
|
||||
if (tweetLines.length > 0) {
|
||||
pushBlock(tweetLines, "quote");
|
||||
}
|
||||
|
||||
const mediaLines = collectMediaLines(block);
|
||||
if (mediaLines.length > 0) {
|
||||
pushBlock(mediaLines, "media");
|
||||
}
|
||||
|
||||
const linkLines = collectLinkLines(block);
|
||||
if (linkLines.length > 0) {
|
||||
pushBlock(linkLines, "text");
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -199,6 +464,7 @@ function renderContentBlocks(
|
||||
listKind = "unordered";
|
||||
orderedIndex = 0;
|
||||
pushBlock([`- ${text}`], "list");
|
||||
pushTrailingMedia(collectMediaLines(block));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -209,6 +475,7 @@ function renderContentBlocks(
|
||||
listKind = "ordered";
|
||||
orderedIndex += 1;
|
||||
pushBlock([`${orderedIndex}. ${text}`], "list");
|
||||
pushTrailingMedia(collectMediaLines(block));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -218,36 +485,43 @@ function renderContentBlocks(
|
||||
switch (type) {
|
||||
case "header-one":
|
||||
pushBlock([`# ${text}`], "heading");
|
||||
pushTrailingMedia(collectMediaLines(block));
|
||||
break;
|
||||
case "header-two":
|
||||
pushBlock([`## ${text}`], "heading");
|
||||
pushTrailingMedia(collectMediaLines(block));
|
||||
break;
|
||||
case "header-three":
|
||||
pushBlock([`### ${text}`], "heading");
|
||||
pushTrailingMedia(collectMediaLines(block));
|
||||
break;
|
||||
case "header-four":
|
||||
pushBlock([`#### ${text}`], "heading");
|
||||
pushTrailingMedia(collectMediaLines(block));
|
||||
break;
|
||||
case "header-five":
|
||||
pushBlock([`##### ${text}`], "heading");
|
||||
pushTrailingMedia(collectMediaLines(block));
|
||||
break;
|
||||
case "header-six":
|
||||
pushBlock([`###### ${text}`], "heading");
|
||||
pushTrailingMedia(collectMediaLines(block));
|
||||
break;
|
||||
case "blockquote": {
|
||||
const quoteLines = text.length > 0 ? text.split("\n") : [""];
|
||||
pushBlock(quoteLines.map((line) => `> ${line}`), "quote");
|
||||
pushTrailingMedia(collectMediaLines(block));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
if (/^XIMGPH_\d+$/.test(text.trim())) {
|
||||
pushTrailingMedia(collectMediaLines(block));
|
||||
break;
|
||||
}
|
||||
pushBlock([text], "text");
|
||||
pushTrailingMedia(collectMediaLines(block));
|
||||
break;
|
||||
}
|
||||
|
||||
const trailingMediaLines = collectMediaLines(block);
|
||||
if (trailingMediaLines.length > 0) {
|
||||
pushBlock(trailingMediaLines, "media");
|
||||
}
|
||||
}
|
||||
|
||||
if (inCodeBlock) {
|
||||
@@ -257,10 +531,36 @@ function renderContentBlocks(
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function formatArticleMarkdown(article: unknown): string {
|
||||
export type FormatArticleResult = {
|
||||
markdown: string;
|
||||
coverUrl: string | null;
|
||||
};
|
||||
|
||||
export function extractReferencedTweetIds(article: unknown): string[] {
|
||||
const candidate = coerceArticleEntity(article);
|
||||
const entityMap = candidate?.content_state?.entityMap;
|
||||
if (!entityMap) return [];
|
||||
|
||||
const ids: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const entry of Object.values(entityMap)) {
|
||||
const value = entry?.value;
|
||||
if (value?.type !== "TWEET") continue;
|
||||
const tweetId = typeof value.data?.tweetId === "string" ? value.data.tweetId : "";
|
||||
if (!tweetId || seen.has(tweetId)) continue;
|
||||
seen.add(tweetId);
|
||||
ids.push(tweetId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function formatArticleMarkdown(
|
||||
article: unknown,
|
||||
options: FormatArticleOptions = {}
|
||||
): FormatArticleResult {
|
||||
const candidate = coerceArticleEntity(article);
|
||||
if (!candidate) {
|
||||
return `\`\`\`json\n${JSON.stringify(article, null, 2)}\n\`\`\``;
|
||||
return { markdown: `\`\`\`json\n${JSON.stringify(article, null, 2)}\n\`\`\``, coverUrl: null };
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
@@ -271,17 +571,25 @@ export function formatArticleMarkdown(article: unknown): string {
|
||||
lines.push(`# ${title}`);
|
||||
}
|
||||
|
||||
const coverUrl = resolveMediaUrl(candidate.cover_media?.media_info);
|
||||
const coverUrl = resolveMediaUrl(candidate.cover_media?.media_info) ?? null;
|
||||
if (coverUrl) {
|
||||
if (lines.length > 0) lines.push("");
|
||||
lines.push(``);
|
||||
usedUrls.add(coverUrl);
|
||||
}
|
||||
|
||||
const blocks = candidate.content_state?.blocks;
|
||||
const entityMap = candidate.content_state?.entityMap;
|
||||
const entityLookup = buildEntityLookup(entityMap);
|
||||
if (Array.isArray(blocks) && blocks.length > 0) {
|
||||
const rendered = renderContentBlocks(blocks, entityMap, mediaById, usedUrls);
|
||||
const mediaLinkMap = buildMediaLinkMap(entityMap);
|
||||
const rendered = renderContentBlocks(
|
||||
blocks,
|
||||
entityMap,
|
||||
entityLookup,
|
||||
mediaById,
|
||||
usedUrls,
|
||||
mediaLinkMap,
|
||||
options.referencedTweets
|
||||
);
|
||||
if (rendered.length > 0) {
|
||||
if (lines.length > 0) lines.push("");
|
||||
lines.push(...rendered);
|
||||
@@ -294,7 +602,7 @@ export function formatArticleMarkdown(article: unknown): string {
|
||||
lines.push(candidate.preview_text.trim());
|
||||
}
|
||||
|
||||
const mediaUrls = collectMediaUrls(candidate, usedUrls, coverUrl);
|
||||
const mediaUrls = collectMediaUrls(candidate, usedUrls, coverUrl ?? undefined);
|
||||
if (mediaUrls.length > 0) {
|
||||
lines.push("", "## Media", "");
|
||||
for (const url of mediaUrls) {
|
||||
@@ -302,5 +610,5 @@ export function formatArticleMarkdown(article: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n").trimEnd();
|
||||
return { markdown: lines.join("\n").trimEnd(), coverUrl };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
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 toHighResUrl(rawUrl: string): string {
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
if (parsed.hostname !== "pbs.twimg.com") return rawUrl;
|
||||
const ext = path.posix.extname(parsed.pathname).replace(/^\./, "").toLowerCase();
|
||||
if (!ext || !IMAGE_EXTENSIONS.has(ext)) return rawUrl;
|
||||
parsed.pathname = parsed.pathname.replace(new RegExp(`\\.${ext}$`), "");
|
||||
parsed.searchParams.set("format", ext === "jpeg" ? "jpg" : ext);
|
||||
parsed.searchParams.set("name", "4096x4096");
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return rawUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[] {
|
||||
const candidates: MarkdownLinkCandidate[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
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" });
|
||||
}
|
||||
}
|
||||
|
||||
MARKDOWN_LINK_RE.lastIndex = 0;
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
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 downloadUrl = toHighResUrl(candidate.url);
|
||||
const response = await fetch(downloadUrl, {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { fetchXTweet } from "./graphql.js";
|
||||
import {
|
||||
extractReferencedTweetIds,
|
||||
type ReferencedTweetInfo,
|
||||
} from "./markdown.js";
|
||||
|
||||
type ResolveReferencedTweetsOptions = {
|
||||
log?: (message: string) => void;
|
||||
};
|
||||
|
||||
function extractReferencedTweetInfo(tweet: any, fallbackTweetId: string): ReferencedTweetInfo {
|
||||
const userCore = tweet?.core?.user_results?.result?.core;
|
||||
const userLegacy = tweet?.core?.user_results?.result?.legacy;
|
||||
|
||||
const authorName =
|
||||
typeof userCore?.name === "string"
|
||||
? userCore.name
|
||||
: typeof userLegacy?.name === "string"
|
||||
? userLegacy.name
|
||||
: undefined;
|
||||
|
||||
const authorUsername =
|
||||
typeof userCore?.screen_name === "string"
|
||||
? userCore.screen_name
|
||||
: typeof userLegacy?.screen_name === "string"
|
||||
? userLegacy.screen_name
|
||||
: undefined;
|
||||
|
||||
const text =
|
||||
tweet?.note_tweet?.note_tweet_results?.result?.text ??
|
||||
tweet?.legacy?.full_text ??
|
||||
tweet?.legacy?.text ??
|
||||
undefined;
|
||||
|
||||
const tweetId =
|
||||
typeof tweet?.rest_id === "string" && tweet.rest_id.length > 0
|
||||
? tweet.rest_id
|
||||
: fallbackTweetId;
|
||||
|
||||
const url = authorUsername
|
||||
? `https://x.com/${authorUsername}/status/${tweetId}`
|
||||
: `https://x.com/i/web/status/${tweetId}`;
|
||||
|
||||
return {
|
||||
id: tweetId,
|
||||
url,
|
||||
authorName,
|
||||
authorUsername,
|
||||
text: typeof text === "string" ? text : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveReferencedTweetsFromArticle(
|
||||
article: unknown,
|
||||
cookieMap: Record<string, string>,
|
||||
options: ResolveReferencedTweetsOptions = {}
|
||||
): Promise<Map<string, ReferencedTweetInfo>> {
|
||||
const log = options.log ?? (() => {});
|
||||
const ids = extractReferencedTweetIds(article);
|
||||
const referencedTweets = new Map<string, ReferencedTweetInfo>();
|
||||
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const tweet = await fetchXTweet(id, cookieMap, false);
|
||||
const info = extractReferencedTweetInfo(tweet, id);
|
||||
referencedTweets.set(id, info);
|
||||
} catch (error) {
|
||||
log(
|
||||
`[x-to-markdown] Failed to fetch referenced tweet ${id}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
referencedTweets.set(id, {
|
||||
id,
|
||||
url: `https://x.com/i/web/status/${id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return referencedTweets;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { hasRequiredXCookies, loadXCookies } from "./cookies.js";
|
||||
import { fetchTweetThread } from "./thread.js";
|
||||
import { formatArticleMarkdown } from "./markdown.js";
|
||||
import { resolveReferencedTweetsFromArticle } from "./referenced-tweets.js";
|
||||
import { formatThreadTweetsMarkdown } from "./thread-markdown.js";
|
||||
import { resolveArticleEntityFromTweet } from "./tweet-article.js";
|
||||
|
||||
@@ -123,22 +124,16 @@ export async function tweetToMarkdown(
|
||||
const requestedUrl = normalizedUrl || buildTweetUrl(username, tweetId) || inputUrl.trim();
|
||||
const rootUrl = buildTweetUrl(username, thread.rootId ?? tweetId) ?? requestedUrl;
|
||||
|
||||
const meta = formatMetaMarkdown({
|
||||
url: rootUrl,
|
||||
requested_url: requestedUrl,
|
||||
author,
|
||||
author_name: name ?? null,
|
||||
author_username: username ?? null,
|
||||
author_url: authorUrl ?? null,
|
||||
tweet_count: thread.totalTweets ?? tweets.length,
|
||||
});
|
||||
|
||||
const parts: string[] = [meta];
|
||||
|
||||
const articleEntity = await resolveArticleEntityFromTweet(firstTweet, cookieMap);
|
||||
let coverImage: string | null = null;
|
||||
let remainingTweets = tweets;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (articleEntity) {
|
||||
const articleMarkdown = formatArticleMarkdown(articleEntity).trimEnd();
|
||||
const referencedTweets = await resolveReferencedTweetsFromArticle(articleEntity, cookieMap, { log });
|
||||
const articleResult = formatArticleMarkdown(articleEntity, { referencedTweets });
|
||||
coverImage = articleResult.coverUrl;
|
||||
const articleMarkdown = articleResult.markdown.trimEnd();
|
||||
if (articleMarkdown) {
|
||||
parts.push(articleMarkdown);
|
||||
const firstTweetText = extractTweetText(firstTweet);
|
||||
@@ -148,6 +143,19 @@ export async function tweetToMarkdown(
|
||||
}
|
||||
}
|
||||
|
||||
const meta = formatMetaMarkdown({
|
||||
url: rootUrl,
|
||||
requestedUrl: requestedUrl,
|
||||
author,
|
||||
authorName: name ?? null,
|
||||
authorUsername: username ?? null,
|
||||
authorUrl: authorUrl ?? null,
|
||||
tweetCount: thread.totalTweets ?? tweets.length,
|
||||
coverImage,
|
||||
});
|
||||
|
||||
parts.unshift(meta);
|
||||
|
||||
if (remainingTweets.length > 0) {
|
||||
const hasArticle = parts.length > 1;
|
||||
if (hasArticle) {
|
||||
|
||||
@@ -40,6 +40,7 @@ export type ArticleEntityMapEntry = {
|
||||
caption?: string;
|
||||
mediaItems?: ArticleEntityMapMediaItem[];
|
||||
url?: string;
|
||||
tweetId?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -13,8 +13,7 @@ Scripts in `scripts/` subdirectory. Replace `${SKILL_DIR}` with this SKILL.md's
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `scripts/main.ts` | Main entry point with CLI options |
|
||||
| `scripts/cjk-emphasis.ts` | Fix CJK emphasis/bold punctuation issues |
|
||||
| `scripts/main.ts` | Main entry point with CLI options (uses remark-cjk-friendly for CJK emphasis) |
|
||||
| `scripts/quotes.ts` | Replace ASCII quotes with fullwidth quotes |
|
||||
| `scripts/autocorrect.ts` | Add CJK/English spacing via autocorrect |
|
||||
|
||||
@@ -58,6 +57,55 @@ Claude performs content analysis and formatting (Steps 1-6), then runs the scrip
|
||||
|
||||
Read the user-specified markdown or plain text file.
|
||||
|
||||
### Step 1.5: Detect Content Type & Confirm
|
||||
|
||||
**Content Type Detection:**
|
||||
|
||||
| Indicator | Classification |
|
||||
|-----------|----------------|
|
||||
| Has `---` YAML frontmatter | Markdown |
|
||||
| Has `#`, `##`, `###` headings | Markdown |
|
||||
| Has `**bold**`, `*italic*` | Markdown |
|
||||
| Has `- ` or `1. ` lists | Markdown |
|
||||
| Has ``` code blocks | Markdown |
|
||||
| Has `> ` blockquotes | Markdown |
|
||||
| None of above | Plain text |
|
||||
|
||||
**Decision Flow:**
|
||||
|
||||
┌─────────────────┬────────────────────────────────────────────────┐
|
||||
│ Content Type │ Action │
|
||||
├─────────────────┼────────────────────────────────────────────────┤
|
||||
│ Plain text │ Proceed to Step 2 (format to markdown) │
|
||||
├─────────────────┼────────────────────────────────────────────────┤
|
||||
│ Markdown │ Use AskUserQuestion to confirm optimization │
|
||||
└─────────────────┴────────────────────────────────────────────────┘
|
||||
|
||||
**If Markdown detected, ask user:**
|
||||
|
||||
```
|
||||
Detected existing markdown formatting. What would you like to do?
|
||||
|
||||
1. Optimize formatting (Recommended)
|
||||
- Add/improve frontmatter, headings, bold, lists
|
||||
- Run typography script (spacing, emphasis fixes)
|
||||
- Output: {filename}-formatted.md
|
||||
|
||||
2. Keep original formatting
|
||||
- Preserve existing markdown structure
|
||||
- Run typography script (spacing, emphasis fixes)
|
||||
- Output: {filename}-formatted.md
|
||||
|
||||
3. Typography fixes only
|
||||
- Run typography script on original file in-place
|
||||
- No copy created, modifies original file directly
|
||||
```
|
||||
|
||||
**Based on user choice:**
|
||||
- **Optimize**: Continue to Step 2-8 (full workflow)
|
||||
- **Keep original**: Skip Steps 2-5, copy file → Step 6-8 (run script on copy)
|
||||
- **Typography only**: Skip Steps 2-6, run Step 7 on original file directly
|
||||
|
||||
### Step 2: Analyze Content Structure
|
||||
|
||||
Identify:
|
||||
@@ -79,7 +127,7 @@ Check for YAML frontmatter (`---` block). Create if missing.
|
||||
| `title` | See Step 4 |
|
||||
| `slug` | Infer from file path (e.g., `posts/2026/01/10/vibe-coding/` → `vibe-coding`) or generate from title |
|
||||
| `summary` | Generate engaging summary (100-150 characters) |
|
||||
| `featureImage` | Check if `imgs/cover.png` exists in same directory; if so, use relative path |
|
||||
| `coverImage` | Check if `imgs/cover.png` exists in same directory; if so, use relative path (also accepted: `featureImage`) |
|
||||
|
||||
### Step 4: Title Handling
|
||||
|
||||
@@ -138,6 +186,10 @@ Examples:
|
||||
- `final.md` → `final-formatted.md`
|
||||
- `draft-v1.md` → `draft-v1-formatted.md`
|
||||
|
||||
**If user chose "Keep original formatting" (from Step 1.5):**
|
||||
- Copy original file to `{filename}-formatted.md` without modifications
|
||||
- Proceed to Step 7 for typography fixes only
|
||||
|
||||
**Backup existing file:**
|
||||
|
||||
If `{filename}-formatted.md` already exists, backup before overwriting:
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
const CJK_PUNCT_COMMON = "。.,、?!:;";
|
||||
const CJK_OPENING_PUNCT = "(〔〖〘〚「『〈《【\u201C\u2018";
|
||||
const CJK_CLOSING_PUNCT = ")〕〗〙〛」』〉》】\u201D\u2019" + CJK_PUNCT_COMMON;
|
||||
const CJK_SCRIPTS =
|
||||
"\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}";
|
||||
|
||||
export const CJK_CLOSING_PUNCT_RE = new RegExp(`[${CJK_CLOSING_PUNCT}]`);
|
||||
export const CJK_OPENING_PUNCT_RE = new RegExp(`^[${CJK_OPENING_PUNCT}]`);
|
||||
export const CJK_CHAR_RE = new RegExp(`[${CJK_SCRIPTS}]`, "u");
|
||||
|
||||
const PUNCT_OR_SYMBOL_RE = /[\p{P}\p{S}]/u;
|
||||
const WORD_CHAR_RE = /[\p{L}\p{N}]/u;
|
||||
|
||||
function findInlineCodeRanges(text: string): Array<[number, number]> {
|
||||
const ranges: Array<[number, number]> = [];
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
if (text[i] !== "`") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let run = 1;
|
||||
while (i + run < text.length && text[i + run] === "`") {
|
||||
run += 1;
|
||||
}
|
||||
|
||||
const start = i;
|
||||
let j = i + run;
|
||||
let found = false;
|
||||
while (j < text.length) {
|
||||
if (text[j] !== "`") {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
let closeRun = 1;
|
||||
while (j + closeRun < text.length && text[j + closeRun] === "`") {
|
||||
closeRun += 1;
|
||||
}
|
||||
if (closeRun === run) {
|
||||
ranges.push([start, j + closeRun - 1]);
|
||||
i = j + closeRun;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
j += closeRun;
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
i = start + run;
|
||||
}
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function isEscaped(text: string, pos: number): boolean {
|
||||
let count = 0;
|
||||
for (let i = pos - 1; i >= 0 && text[i] === "\\"; i -= 1) {
|
||||
count += 1;
|
||||
}
|
||||
return count % 2 === 1;
|
||||
}
|
||||
|
||||
function isWhitespaceChar(ch: string | undefined): boolean {
|
||||
return !ch || /\s/u.test(ch);
|
||||
}
|
||||
|
||||
function isPunctuationOrSymbol(ch: string | undefined): boolean {
|
||||
return !!ch && PUNCT_OR_SYMBOL_RE.test(ch);
|
||||
}
|
||||
|
||||
function fixCjkEmphasisSpacingInBlock(block: string): string {
|
||||
const codeRanges = findInlineCodeRanges(block);
|
||||
let rangeIndex = 0;
|
||||
|
||||
const delimiters: Array<{
|
||||
pos: number;
|
||||
canOpen: boolean;
|
||||
canClose: boolean;
|
||||
}> = [];
|
||||
let cursor = 0;
|
||||
while (cursor < block.length - 1) {
|
||||
if (rangeIndex < codeRanges.length && cursor >= codeRanges[rangeIndex][0]) {
|
||||
if (cursor <= codeRanges[rangeIndex][1]) {
|
||||
cursor = codeRanges[rangeIndex][1] + 1;
|
||||
continue;
|
||||
}
|
||||
rangeIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
block[cursor] === "*" &&
|
||||
block[cursor + 1] === "*" &&
|
||||
block[cursor - 1] !== "*" &&
|
||||
block[cursor + 2] !== "*" &&
|
||||
!isEscaped(block, cursor)
|
||||
) {
|
||||
const before = block[cursor - 1];
|
||||
const after = block[cursor + 2];
|
||||
const beforeIsSpace = isWhitespaceChar(before);
|
||||
const afterIsSpace = isWhitespaceChar(after);
|
||||
const beforeIsPunct = isPunctuationOrSymbol(before);
|
||||
const afterIsPunct = isPunctuationOrSymbol(after);
|
||||
const leftFlanking =
|
||||
!afterIsSpace && (!afterIsPunct || beforeIsSpace || beforeIsPunct);
|
||||
const rightFlanking =
|
||||
!beforeIsSpace && (!beforeIsPunct || afterIsSpace || afterIsPunct);
|
||||
const cjkPunctBefore = !!before && CJK_CLOSING_PUNCT_RE.test(before);
|
||||
const wordAfter = !!after && WORD_CHAR_RE.test(after);
|
||||
|
||||
delimiters.push({
|
||||
pos: cursor,
|
||||
canOpen: leftFlanking,
|
||||
canClose: rightFlanking || (cjkPunctBefore && wordAfter),
|
||||
});
|
||||
cursor += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
const stack: Array<{ pos: number }> = [];
|
||||
const pairs: Array<{ open: number; close: number }> = [];
|
||||
for (const delimiter of delimiters) {
|
||||
if (delimiter.canClose) {
|
||||
let openerIndex = -1;
|
||||
for (let j = stack.length - 1; j >= 0; j -= 1) {
|
||||
openerIndex = j;
|
||||
break;
|
||||
}
|
||||
if (openerIndex !== -1) {
|
||||
const opener = stack.splice(openerIndex, 1)[0];
|
||||
pairs.push({ open: opener.pos, close: delimiter.pos });
|
||||
}
|
||||
}
|
||||
if (delimiter.canOpen) {
|
||||
stack.push({ pos: delimiter.pos });
|
||||
}
|
||||
}
|
||||
|
||||
if (pairs.length === 0) return block;
|
||||
|
||||
const insertPositions = new Set<number>();
|
||||
for (const pair of pairs) {
|
||||
const insideLast = block[pair.close - 1];
|
||||
const afterClose = block[pair.close + 2];
|
||||
if (!afterClose) continue;
|
||||
if (
|
||||
CJK_CLOSING_PUNCT_RE.test(insideLast) &&
|
||||
WORD_CHAR_RE.test(afterClose)
|
||||
) {
|
||||
insertPositions.add(pair.close + 2);
|
||||
}
|
||||
}
|
||||
|
||||
if (insertPositions.size === 0) return block;
|
||||
|
||||
let result = "";
|
||||
for (let idx = 0; idx < block.length; idx += 1) {
|
||||
if (insertPositions.has(idx)) {
|
||||
result += " ";
|
||||
}
|
||||
result += block[idx];
|
||||
}
|
||||
if (insertPositions.has(block.length)) {
|
||||
result += " ";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function fixCjkEmphasisSpacing(content: string): string {
|
||||
const parts = content.split(/(^```[\s\S]*?^```|^~~~[\s\S]*?^~~~)/m);
|
||||
return parts
|
||||
.map((part, i) => {
|
||||
if (i % 2 === 1) return part;
|
||||
const blocks = part.split(/(\n\s*\n+)/);
|
||||
return blocks
|
||||
.map((block, index) => {
|
||||
if (index % 2 === 1) return block;
|
||||
return fixCjkEmphasisSpacingInBlock(block);
|
||||
})
|
||||
.join("");
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
@@ -1,17 +1,12 @@
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { unified } from "unified";
|
||||
import remarkParse from "remark-parse";
|
||||
import remarkCjkFriendly from "remark-cjk-friendly";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkFrontmatter from "remark-frontmatter";
|
||||
import remarkStringify from "remark-stringify";
|
||||
import { visit } from "unist-util-visit";
|
||||
import YAML from "yaml";
|
||||
import {
|
||||
fixCjkEmphasisSpacing,
|
||||
CJK_CLOSING_PUNCT_RE,
|
||||
CJK_OPENING_PUNCT_RE,
|
||||
CJK_CHAR_RE,
|
||||
} from "./cjk-emphasis";
|
||||
import { replaceQuotes } from "./quotes";
|
||||
import { applyAutocorrect } from "./autocorrect";
|
||||
|
||||
@@ -36,6 +31,12 @@ const DEFAULT_OPTIONS: Required<FormatOptions> = {
|
||||
emphasis: true,
|
||||
};
|
||||
|
||||
function decodeHtmlEntities(text: string): string {
|
||||
return text.replace(/&#x([0-9A-Fa-f]+);/g, (_, hex) =>
|
||||
String.fromCodePoint(parseInt(hex, 16))
|
||||
);
|
||||
}
|
||||
|
||||
function formatFrontmatter(value: string): string | null {
|
||||
try {
|
||||
const doc = YAML.parseDocument(value);
|
||||
@@ -49,12 +50,9 @@ function formatMarkdownContent(
|
||||
content: string,
|
||||
options: Required<FormatOptions>
|
||||
): string {
|
||||
if (options.emphasis) {
|
||||
content = fixCjkEmphasisSpacing(content);
|
||||
}
|
||||
|
||||
const processor = unified()
|
||||
.use(remarkParse)
|
||||
.use(options.emphasis ? remarkCjkFriendly : [])
|
||||
.use(remarkGfm)
|
||||
.use(remarkFrontmatter, ["yaml"])
|
||||
.use(remarkStringify, {
|
||||
@@ -63,7 +61,7 @@ function formatMarkdownContent(
|
||||
|
||||
const tree = processor.parse(content);
|
||||
|
||||
visit(tree, (node, _index, parent) => {
|
||||
visit(tree, (node) => {
|
||||
if (node.type === "text" && options.quotes) {
|
||||
const textNode = node as { value: string };
|
||||
textNode.value = replaceQuotes(textNode.value);
|
||||
@@ -77,54 +75,11 @@ function formatMarkdownContent(
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
options.emphasis &&
|
||||
(node.type === "strong" ||
|
||||
node.type === "emphasis" ||
|
||||
node.type === "delete") &&
|
||||
parent
|
||||
) {
|
||||
const siblings = (parent as { children: typeof node[] }).children;
|
||||
const idx = siblings.indexOf(node);
|
||||
const children = (node as { children: typeof node[] }).children;
|
||||
if (!children || children.length === 0) return;
|
||||
|
||||
const lastChild = children[children.length - 1];
|
||||
if (lastChild.type === "text") {
|
||||
const lastText = (lastChild as { value: string }).value;
|
||||
if (
|
||||
CJK_CLOSING_PUNCT_RE.test(lastText.slice(-1)) &&
|
||||
idx + 1 < siblings.length
|
||||
) {
|
||||
const nextSib = siblings[idx + 1];
|
||||
if (nextSib.type === "text") {
|
||||
const nextText = (nextSib as { value: string }).value;
|
||||
if (CJK_CHAR_RE.test(nextText.charAt(0))) {
|
||||
(nextSib as { value: string }).value = " " + nextText;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const firstChild = children[0];
|
||||
if (firstChild.type === "text") {
|
||||
const firstText = (firstChild as { value: string }).value;
|
||||
if (CJK_OPENING_PUNCT_RE.test(firstText) && idx > 0) {
|
||||
const prevSib = siblings[idx - 1];
|
||||
if (prevSib.type === "text") {
|
||||
const prevText = (prevSib as { value: string }).value;
|
||||
if (CJK_CHAR_RE.test(prevText.charAt(prevText.length - 1))) {
|
||||
(prevSib as { value: string }).value = prevText + " ";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let result = processor.stringify(tree);
|
||||
if (options.emphasis) {
|
||||
result = fixCjkEmphasisSpacing(result);
|
||||
result = decodeHtmlEntities(result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
+1125
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"remark-cjk-friendly": "^1.1.0",
|
||||
"remark-frontmatter": "^5.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: baoyu-image-gen
|
||||
description: AI image generation with OpenAI, Google and DashScope APIs. Supports text-to-image, reference images, aspect ratios. Sequential by default; parallel generation available on request. Use when user asks to generate, create, or draw images.
|
||||
description: AI image generation with OpenAI, Google, DashScope and Replicate APIs. Supports text-to-image, reference images, aspect ratios. Sequential by default; parallel generation available on request. Use when user asks to generate, create, or draw images.
|
||||
---
|
||||
|
||||
# Image Generation (AI SDK)
|
||||
|
||||
Official API-based image generation. Supports OpenAI, Google and DashScope (阿里通义万象) providers.
|
||||
Official API-based image generation. Supports OpenAI, Google, DashScope (阿里通义万象) and Replicate providers.
|
||||
|
||||
## Script Directory
|
||||
|
||||
@@ -13,35 +13,32 @@ Official API-based image generation. Supports OpenAI, Google and DashScope (阿
|
||||
1. `SKILL_DIR` = this SKILL.md file's directory
|
||||
2. Script path = `${SKILL_DIR}/scripts/main.ts`
|
||||
|
||||
## Preferences (EXTEND.md)
|
||||
## Step 0: Load Preferences ⛔ BLOCKING
|
||||
|
||||
Use Bash to check EXTEND.md existence (priority order):
|
||||
**CRITICAL**: This step MUST complete BEFORE any image generation. Do NOT skip or defer.
|
||||
|
||||
Check EXTEND.md existence (priority: project → user):
|
||||
|
||||
```bash
|
||||
# Check project-level first
|
||||
test -f .baoyu-skills/baoyu-image-gen/EXTEND.md && echo "project"
|
||||
|
||||
# Then user-level (cross-platform: $HOME works on macOS/Linux/WSL)
|
||||
test -f "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "user"
|
||||
```
|
||||
|
||||
┌──────────────────────────────────────────────────┬───────────────────┐
|
||||
│ Path │ Location │
|
||||
├──────────────────────────────────────────────────┼───────────────────┤
|
||||
│ .baoyu-skills/baoyu-image-gen/EXTEND.md │ Project directory │
|
||||
├──────────────────────────────────────────────────┼───────────────────┤
|
||||
│ $HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md │ User home │
|
||||
└──────────────────────────────────────────────────┴───────────────────┘
|
||||
| Result | Action |
|
||||
|--------|--------|
|
||||
| Found | Load, parse, apply settings. If `default_model.[provider]` is null → ask model only (Flow 2) |
|
||||
| Not found | ⛔ Run first-time setup ([references/config/first-time-setup.md](references/config/first-time-setup.md)) → Save EXTEND.md → Then continue |
|
||||
|
||||
┌───────────┬───────────────────────────────────────────────────────────────────────────┐
|
||||
│ Result │ Action │
|
||||
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||
│ Found │ Read, parse, apply settings │
|
||||
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||
│ Not found │ Use defaults │
|
||||
└───────────┴───────────────────────────────────────────────────────────────────────────┘
|
||||
**CRITICAL**: If not found, complete the full setup (provider + model + quality + save location) using AskUserQuestion BEFORE generating any images. Generation is BLOCKED until EXTEND.md is created.
|
||||
|
||||
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio
|
||||
| Path | Location |
|
||||
|------|----------|
|
||||
| `.baoyu-skills/baoyu-image-gen/EXTEND.md` | Project directory |
|
||||
| `$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md` | User home |
|
||||
|
||||
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio | Default image size | Default models
|
||||
|
||||
Schema: `references/config/preferences-schema.md`
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -58,14 +55,23 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --quali
|
||||
# From prompt files
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||
|
||||
# With reference images (Google multimodal only)
|
||||
# With reference images (Google multimodal or OpenAI edits)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Make blue" --image out.png --ref source.png
|
||||
|
||||
# With reference images (explicit provider/model)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Make blue" --image out.png --provider google --model gemini-3-pro-image-preview --ref source.png
|
||||
|
||||
# Specific provider
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider openai
|
||||
|
||||
# DashScope (阿里通义万象)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "一只可爱的猫" --image out.png --provider dashscope
|
||||
|
||||
# Replicate (google/nano-banana-pro)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
|
||||
|
||||
# Replicate with specific model
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate --model google/nano-banana
|
||||
```
|
||||
|
||||
## Options
|
||||
@@ -75,13 +81,13 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "一只可爱的猫" --image ou
|
||||
| `--prompt <text>`, `-p` | Prompt text |
|
||||
| `--promptfiles <files...>` | Read prompt from files (concatenated) |
|
||||
| `--image <path>` | Output image path (required) |
|
||||
| `--provider google\|openai\|dashscope` | Force provider (default: google) |
|
||||
| `--model <id>`, `-m` | Model ID |
|
||||
| `--provider google\|openai\|dashscope\|replicate` | Force provider (default: google) |
|
||||
| `--model <id>`, `-m` | Model ID (Google: `gemini-3-pro-image-preview`, `gemini-3.1-flash-image-preview`; OpenAI: `gpt-image-1.5`) |
|
||||
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
||||
| `--size <WxH>` | Size (e.g., `1024x1024`) |
|
||||
| `--quality normal\|2k` | Quality preset (default: 2k) |
|
||||
| `--imageSize 1K\|2K\|4K` | Image size for Google (default: from quality) |
|
||||
| `--ref <files...>` | Reference images (Google multimodal only) |
|
||||
| `--ref <files...>` | Reference images. Supported by Google multimodal (`gemini-3-pro-image-preview`, `gemini-3-flash-preview`, `gemini-3.1-flash-image-preview`) and OpenAI edits (GPT Image models). If provider omitted: Google first, then OpenAI |
|
||||
| `--n <count>` | Number of images |
|
||||
| `--json` | JSON output |
|
||||
|
||||
@@ -92,20 +98,48 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "一只可爱的猫" --image ou
|
||||
| `OPENAI_API_KEY` | OpenAI API key |
|
||||
| `GOOGLE_API_KEY` | Google API key |
|
||||
| `DASHSCOPE_API_KEY` | DashScope API key (阿里云) |
|
||||
| `REPLICATE_API_TOKEN` | Replicate API token |
|
||||
| `OPENAI_IMAGE_MODEL` | OpenAI model override |
|
||||
| `GOOGLE_IMAGE_MODEL` | Google model override |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope model override (default: z-image-turbo) |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate model override (default: google/nano-banana-pro) |
|
||||
| `OPENAI_BASE_URL` | Custom OpenAI endpoint |
|
||||
| `GOOGLE_BASE_URL` | Custom Google endpoint |
|
||||
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint |
|
||||
| `REPLICATE_BASE_URL` | Custom Replicate endpoint |
|
||||
|
||||
**Load Priority**: CLI args > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
|
||||
**Load Priority**: CLI args > EXTEND.md > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
|
||||
|
||||
## Replicate Model Configuration
|
||||
|
||||
When using `--provider replicate`, the model can be configured in the following ways (highest priority first):
|
||||
|
||||
1. CLI flag: `--model <owner/name>`
|
||||
2. EXTEND.md: `default_model.replicate`
|
||||
3. Env var: `REPLICATE_IMAGE_MODEL`
|
||||
4. Built-in default: `google/nano-banana-pro`
|
||||
|
||||
Supported model formats:
|
||||
|
||||
- `owner/name` (recommended for official models), e.g. `google/nano-banana-pro`
|
||||
- `owner/name:version` (community models by version), e.g. `stability-ai/sdxl:<version>`
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Use Replicate default model
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
|
||||
|
||||
# Override model explicitly
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate --model google/nano-banana
|
||||
```
|
||||
|
||||
## Provider Selection
|
||||
|
||||
1. `--provider` specified → use it
|
||||
2. Only one API key available → use that provider
|
||||
3. Multiple available → default to Google
|
||||
1. `--ref` provided + no `--provider` → auto-select Google first, then OpenAI, then Replicate
|
||||
2. `--provider` specified → use it (if `--ref`, must be `google`, `openai`, or `replicate`)
|
||||
3. Only one API key available → use that provider
|
||||
4. Multiple available → default to Google
|
||||
|
||||
## Quality Presets
|
||||
|
||||
@@ -155,7 +189,7 @@ Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`
|
||||
- Missing API key → error with setup instructions
|
||||
- Generation failure → auto-retry once
|
||||
- Invalid aspect ratio → warning, proceed with default
|
||||
- Reference images with non-multimodal model → warning, ignore refs
|
||||
- Reference images with unsupported provider/model → error with fix hint (switch to Google multimodal: `gemini-3-pro-image-preview`, `gemini-3.1-flash-image-preview`; or OpenAI GPT Image edits)
|
||||
|
||||
## Extension Support
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
---
|
||||
name: first-time-setup
|
||||
description: First-time setup and default model selection flow for baoyu-image-gen
|
||||
---
|
||||
|
||||
# First-Time Setup
|
||||
|
||||
## Overview
|
||||
|
||||
Triggered when:
|
||||
1. No EXTEND.md found → full setup (provider + model + preferences)
|
||||
2. EXTEND.md found but `default_model.[provider]` is null → model selection only
|
||||
|
||||
## Setup Flow
|
||||
|
||||
```
|
||||
No EXTEND.md found EXTEND.md found, model null
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────┐ ┌──────────────────────┐
|
||||
│ AskUserQuestion │ │ AskUserQuestion │
|
||||
│ (full setup) │ │ (model only) │
|
||||
└─────────────────────┘ └──────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────┐ ┌──────────────────────┐
|
||||
│ Create EXTEND.md │ │ Update EXTEND.md │
|
||||
└─────────────────────┘ └──────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
Continue Continue
|
||||
```
|
||||
|
||||
## Flow 1: No EXTEND.md (Full Setup)
|
||||
|
||||
**Language**: Use user's input language or saved language preference.
|
||||
|
||||
Use AskUserQuestion with ALL questions in ONE call:
|
||||
|
||||
### Question 1: Default Provider
|
||||
|
||||
```yaml
|
||||
header: "Provider"
|
||||
question: "Default image generation provider?"
|
||||
options:
|
||||
- label: "Google (Recommended)"
|
||||
description: "Gemini multimodal - high quality, reference images, flexible sizes"
|
||||
- label: "OpenAI"
|
||||
description: "GPT Image - consistent quality, reliable output"
|
||||
- label: "DashScope"
|
||||
description: "Alibaba Cloud - z-image-turbo, good for Chinese content"
|
||||
- label: "Replicate"
|
||||
description: "Community models - nano-banana-pro, flexible model selection"
|
||||
```
|
||||
|
||||
### Question 2: Default Google Model
|
||||
|
||||
Only show if user selected Google or auto-detect (no explicit provider).
|
||||
|
||||
```yaml
|
||||
header: "Google Model"
|
||||
question: "Default Google image generation model?"
|
||||
options:
|
||||
- label: "gemini-3-pro-image-preview (Recommended)"
|
||||
description: "Highest quality, best for production use"
|
||||
- label: "gemini-3.1-flash-image-preview"
|
||||
description: "Fast generation, good quality, lower cost"
|
||||
- label: "gemini-3-flash-preview"
|
||||
description: "Fast generation, balanced quality and speed"
|
||||
```
|
||||
|
||||
### Question 3: Default Quality
|
||||
|
||||
```yaml
|
||||
header: "Quality"
|
||||
question: "Default image quality?"
|
||||
options:
|
||||
- label: "2k (Recommended)"
|
||||
description: "2048px - covers, illustrations, infographics"
|
||||
- label: "normal"
|
||||
description: "1024px - quick previews, drafts"
|
||||
```
|
||||
|
||||
### Question 4: 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-image-gen/EXTEND.md` | Current project |
|
||||
| User | `$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md` | All projects |
|
||||
|
||||
### EXTEND.md Template
|
||||
|
||||
```yaml
|
||||
---
|
||||
version: 1
|
||||
default_provider: [selected provider or null]
|
||||
default_quality: [selected quality]
|
||||
default_aspect_ratio: null
|
||||
default_image_size: null
|
||||
default_model:
|
||||
google: [selected google model or null]
|
||||
openai: null
|
||||
dashscope: null
|
||||
replicate: null
|
||||
---
|
||||
```
|
||||
|
||||
## Flow 2: EXTEND.md Exists, Model Null
|
||||
|
||||
When EXTEND.md exists but `default_model.[current_provider]` is null, ask ONLY the model question for the current provider.
|
||||
|
||||
### Google Model Selection
|
||||
|
||||
```yaml
|
||||
header: "Google Model"
|
||||
question: "Choose a default Google image generation model?"
|
||||
options:
|
||||
- label: "gemini-3-pro-image-preview (Recommended)"
|
||||
description: "Highest quality, best for production use"
|
||||
- label: "gemini-3.1-flash-image-preview"
|
||||
description: "Fast generation, good quality, lower cost"
|
||||
- label: "gemini-3-flash-preview"
|
||||
description: "Fast generation, balanced quality and speed"
|
||||
```
|
||||
|
||||
### OpenAI Model Selection
|
||||
|
||||
```yaml
|
||||
header: "OpenAI Model"
|
||||
question: "Choose a default OpenAI image generation model?"
|
||||
options:
|
||||
- label: "gpt-image-1.5 (Recommended)"
|
||||
description: "Latest GPT Image model, high quality"
|
||||
- label: "gpt-image-1"
|
||||
description: "Previous generation GPT Image model"
|
||||
```
|
||||
|
||||
### DashScope Model Selection
|
||||
|
||||
```yaml
|
||||
header: "DashScope Model"
|
||||
question: "Choose a default DashScope image generation model?"
|
||||
options:
|
||||
- label: "z-image-turbo (Recommended)"
|
||||
description: "Fast generation, good quality"
|
||||
- label: "z-image-ultra"
|
||||
description: "Higher quality, slower generation"
|
||||
```
|
||||
|
||||
### Replicate Model Selection
|
||||
|
||||
```yaml
|
||||
header: "Replicate Model"
|
||||
question: "Choose a default Replicate image generation model?"
|
||||
options:
|
||||
- label: "google/nano-banana-pro (Recommended)"
|
||||
description: "Google's fast image model on Replicate"
|
||||
- label: "google/nano-banana"
|
||||
description: "Google's base image model on Replicate"
|
||||
```
|
||||
|
||||
### Update EXTEND.md
|
||||
|
||||
After user selects a model:
|
||||
|
||||
1. Read existing EXTEND.md
|
||||
2. If `default_model:` section exists → update the provider-specific key
|
||||
3. If `default_model:` section missing → add the full section:
|
||||
|
||||
```yaml
|
||||
default_model:
|
||||
google: [value or null]
|
||||
openai: [value or null]
|
||||
dashscope: [value or null]
|
||||
replicate: [value or null]
|
||||
```
|
||||
|
||||
Only set the selected provider's model; leave others as their current value or null.
|
||||
|
||||
## After Setup
|
||||
|
||||
1. Create directory if needed
|
||||
2. Write/update EXTEND.md with frontmatter
|
||||
3. Confirm: "Preferences saved to [path]"
|
||||
4. Continue with image generation
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: preferences-schema
|
||||
description: EXTEND.md YAML schema for baoyu-image-gen user preferences
|
||||
---
|
||||
|
||||
# Preferences Schema
|
||||
|
||||
## Full Schema
|
||||
|
||||
```yaml
|
||||
---
|
||||
version: 1
|
||||
|
||||
default_provider: null # google|openai|dashscope|replicate|null (null = auto-detect)
|
||||
|
||||
default_quality: null # normal|2k|null (null = use default: 2k)
|
||||
|
||||
default_aspect_ratio: null # "16:9"|"1:1"|"4:3"|"3:4"|"2.35:1"|null
|
||||
|
||||
default_image_size: null # 1K|2K|4K|null (Google only, overrides quality)
|
||||
|
||||
default_model:
|
||||
google: null # e.g., "gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview"
|
||||
openai: null # e.g., "gpt-image-1.5"
|
||||
dashscope: null # e.g., "z-image-turbo"
|
||||
replicate: null # e.g., "google/nano-banana-pro"
|
||||
---
|
||||
```
|
||||
|
||||
## Field Reference
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `version` | int | 1 | Schema version |
|
||||
| `default_provider` | string\|null | null | Default provider (null = auto-detect) |
|
||||
| `default_quality` | string\|null | null | Default quality (null = 2k) |
|
||||
| `default_aspect_ratio` | string\|null | null | Default aspect ratio |
|
||||
| `default_image_size` | string\|null | null | Google image size (overrides quality) |
|
||||
| `default_model.google` | string\|null | null | Google default model |
|
||||
| `default_model.openai` | string\|null | null | OpenAI default model |
|
||||
| `default_model.dashscope` | string\|null | null | DashScope default model |
|
||||
| `default_model.replicate` | string\|null | null | Replicate default model |
|
||||
|
||||
## Examples
|
||||
|
||||
**Minimal**:
|
||||
```yaml
|
||||
---
|
||||
version: 1
|
||||
default_provider: google
|
||||
default_quality: 2k
|
||||
---
|
||||
```
|
||||
|
||||
**Full**:
|
||||
```yaml
|
||||
---
|
||||
version: 1
|
||||
default_provider: google
|
||||
default_quality: 2k
|
||||
default_aspect_ratio: "16:9"
|
||||
default_image_size: 2K
|
||||
default_model:
|
||||
google: "gemini-3-pro-image-preview"
|
||||
openai: "gpt-image-1.5"
|
||||
dashscope: "z-image-turbo"
|
||||
replicate: "google/nano-banana-pro"
|
||||
---
|
||||
```
|
||||
@@ -1,8 +1,8 @@
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { homedir } from "node:os";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import type { CliArgs, Provider } from "./types";
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import type { CliArgs, Provider, ExtendConfig } from "./types";
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(`Usage:
|
||||
@@ -14,13 +14,13 @@ Options:
|
||||
-p, --prompt <text> Prompt text
|
||||
--promptfiles <files...> Read prompt from files (concatenated)
|
||||
--image <path> Output image path (required)
|
||||
--provider google|openai|dashscope Force provider (auto-detect by default)
|
||||
--provider google|openai|dashscope|replicate Force provider (auto-detect by default)
|
||||
-m, --model <id> Model ID
|
||||
--ar <ratio> Aspect ratio (e.g., 16:9, 1:1, 4:3)
|
||||
--size <WxH> Size (e.g., 1024x1024)
|
||||
--quality normal|2k Quality preset (default: 2k)
|
||||
--imageSize 1K|2K|4K Image size for Google (default: from quality)
|
||||
--ref <files...> Reference images (Google multimodal only)
|
||||
--ref <files...> Reference images (Google multimodal or OpenAI edits)
|
||||
--n <count> Number of images (default: 1)
|
||||
--json JSON output
|
||||
-h, --help Show help
|
||||
@@ -30,14 +30,18 @@ Environment variables:
|
||||
GOOGLE_API_KEY Google API key
|
||||
GEMINI_API_KEY Gemini API key (alias for GOOGLE_API_KEY)
|
||||
DASHSCOPE_API_KEY DashScope API key (阿里云通义万象)
|
||||
REPLICATE_API_TOKEN Replicate API token
|
||||
OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-1.5)
|
||||
GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image-preview)
|
||||
DASHSCOPE_IMAGE_MODEL Default DashScope model (z-image-turbo)
|
||||
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-pro)
|
||||
OPENAI_BASE_URL Custom OpenAI endpoint
|
||||
OPENAI_IMAGE_USE_CHAT Use /chat/completions instead of /images/generations (true|false)
|
||||
GOOGLE_BASE_URL Custom Google endpoint
|
||||
DASHSCOPE_BASE_URL Custom DashScope endpoint
|
||||
REPLICATE_BASE_URL Custom Replicate endpoint
|
||||
|
||||
Env file load order: CLI args > process.env > <cwd>/.baoyu-skills/.env > ~/.baoyu-skills/.env`);
|
||||
Env file load order: CLI args > EXTEND.md > process.env > <cwd>/.baoyu-skills/.env > ~/.baoyu-skills/.env`);
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
@@ -49,7 +53,7 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
size: null,
|
||||
quality: "2k",
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
@@ -108,7 +112,7 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
|
||||
if (a === "--provider") {
|
||||
const v = argv[++i];
|
||||
if (v !== "google" && v !== "openai" && v !== "dashscope") throw new Error(`Invalid provider: ${v}`);
|
||||
if (v !== "google" && v !== "openai" && v !== "dashscope" && v !== "replicate") throw new Error(`Invalid provider: ${v}`);
|
||||
out.provider = v;
|
||||
continue;
|
||||
}
|
||||
@@ -215,6 +219,87 @@ async function loadEnv(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function extractYamlFrontMatter(content: string): string | null {
|
||||
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*$/m);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
const config: Partial<ExtendConfig> = {};
|
||||
const lines = yaml.split("\n");
|
||||
let currentKey: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
if (trimmed.includes(":") && !trimmed.startsWith("-")) {
|
||||
const colonIdx = trimmed.indexOf(":");
|
||||
const key = trimmed.slice(0, colonIdx).trim();
|
||||
let value = trimmed.slice(colonIdx + 1).trim();
|
||||
|
||||
if (value === "null" || value === "") {
|
||||
value = "null";
|
||||
}
|
||||
|
||||
if (key === "version") {
|
||||
config.version = value === "null" ? 1 : parseInt(value, 10);
|
||||
} else if (key === "default_provider") {
|
||||
config.default_provider = value === "null" ? null : (value as Provider);
|
||||
} else if (key === "default_quality") {
|
||||
config.default_quality = value === "null" ? null : (value as "normal" | "2k");
|
||||
} else if (key === "default_aspect_ratio") {
|
||||
const cleaned = value.replace(/['"]/g, "");
|
||||
config.default_aspect_ratio = cleaned === "null" ? null : cleaned;
|
||||
} else if (key === "default_image_size") {
|
||||
config.default_image_size = value === "null" ? null : (value as "1K" | "2K" | "4K");
|
||||
} else if (key === "default_model") {
|
||||
config.default_model = { google: null, openai: null, dashscope: null, replicate: null };
|
||||
currentKey = "default_model";
|
||||
} else if (currentKey === "default_model" && (key === "google" || key === "openai" || key === "dashscope" || key === "replicate")) {
|
||||
const cleaned = value.replace(/['"]/g, "");
|
||||
config.default_model![key] = cleaned === "null" ? null : cleaned;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async function loadExtendConfig(): Promise<Partial<ExtendConfig>> {
|
||||
const home = homedir();
|
||||
const cwd = process.cwd();
|
||||
|
||||
const paths = [
|
||||
path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
|
||||
path.join(home, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
|
||||
];
|
||||
|
||||
for (const p of paths) {
|
||||
try {
|
||||
const content = await readFile(p, "utf8");
|
||||
const yaml = extractYamlFrontMatter(content);
|
||||
if (!yaml) continue;
|
||||
|
||||
return parseSimpleYaml(yaml);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function mergeConfig(args: CliArgs, extend: Partial<ExtendConfig>): CliArgs {
|
||||
return {
|
||||
...args,
|
||||
provider: args.provider ?? extend.default_provider ?? null,
|
||||
quality: args.quality ?? extend.default_quality ?? null,
|
||||
aspectRatio: args.aspectRatio ?? extend.default_aspect_ratio ?? null,
|
||||
imageSize: args.imageSize ?? extend.default_image_size ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function readPromptFromFiles(files: string[]): Promise<string> {
|
||||
const parts: string[] = [];
|
||||
for (const f of files) {
|
||||
@@ -242,28 +327,67 @@ function normalizeOutputImagePath(p: string): string {
|
||||
}
|
||||
|
||||
function detectProvider(args: CliArgs): Provider {
|
||||
if (args.referenceImages.length > 0 && args.provider && args.provider !== "google" && args.provider !== "openai" && args.provider !== "replicate") {
|
||||
throw new Error(
|
||||
"Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), or --provider replicate."
|
||||
);
|
||||
}
|
||||
|
||||
if (args.provider) return args.provider;
|
||||
|
||||
const hasGoogle = !!(process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY);
|
||||
const hasOpenai = !!process.env.OPENAI_API_KEY;
|
||||
const hasDashscope = !!process.env.DASHSCOPE_API_KEY;
|
||||
const hasReplicate = !!process.env.REPLICATE_API_TOKEN;
|
||||
|
||||
const available = [hasGoogle && "google", hasOpenai && "openai", hasDashscope && "dashscope"].filter(Boolean) as Provider[];
|
||||
if (args.referenceImages.length > 0) {
|
||||
if (hasGoogle) return "google";
|
||||
if (hasOpenai) return "openai";
|
||||
if (hasReplicate) return "replicate";
|
||||
throw new Error(
|
||||
"Reference images require Google, OpenAI or Replicate. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, or REPLICATE_API_TOKEN, or remove --ref."
|
||||
);
|
||||
}
|
||||
|
||||
const available = [hasGoogle && "google", hasOpenai && "openai", hasDashscope && "dashscope", hasReplicate && "replicate"].filter(Boolean) as Provider[];
|
||||
|
||||
if (available.length === 1) return available[0]!;
|
||||
if (available.length > 1) return available[0]!;
|
||||
|
||||
throw new Error(
|
||||
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, or DASHSCOPE_API_KEY.\n" +
|
||||
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, DASHSCOPE_API_KEY, or REPLICATE_API_TOKEN.\n" +
|
||||
"Create ~/.baoyu-skills/.env or <cwd>/.baoyu-skills/.env with your keys."
|
||||
);
|
||||
}
|
||||
|
||||
async function validateReferenceImages(referenceImages: string[]): Promise<void> {
|
||||
for (const refPath of referenceImages) {
|
||||
const fullPath = path.resolve(refPath);
|
||||
try {
|
||||
await access(fullPath);
|
||||
} catch {
|
||||
throw new Error(`Reference image not found: ${fullPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ProviderModule = {
|
||||
getDefaultModel: () => string;
|
||||
generateImage: (prompt: string, model: string, args: CliArgs) => Promise<Uint8Array>;
|
||||
};
|
||||
|
||||
function isRetryableGenerationError(error: unknown): boolean {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
const nonRetryableMarkers = [
|
||||
"Reference image",
|
||||
"not supported",
|
||||
"only supported",
|
||||
"No API key found",
|
||||
"is required",
|
||||
];
|
||||
return !nonRetryableMarkers.some((marker) => msg.includes(marker));
|
||||
}
|
||||
|
||||
async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
|
||||
if (provider === "google") {
|
||||
return (await import("./providers/google")) as ProviderModule;
|
||||
@@ -271,6 +395,9 @@ async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
|
||||
if (provider === "dashscope") {
|
||||
return (await import("./providers/dashscope")) as ProviderModule;
|
||||
}
|
||||
if (provider === "replicate") {
|
||||
return (await import("./providers/replicate")) as ProviderModule;
|
||||
}
|
||||
return (await import("./providers/openai")) as ProviderModule;
|
||||
}
|
||||
|
||||
@@ -283,9 +410,13 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
await loadEnv();
|
||||
const extendConfig = await loadExtendConfig();
|
||||
const mergedArgs = mergeConfig(args, extendConfig);
|
||||
|
||||
let prompt: string | null = args.prompt;
|
||||
if (!prompt && args.promptFiles.length > 0) prompt = await readPromptFromFiles(args.promptFiles);
|
||||
if (!mergedArgs.quality) mergedArgs.quality = "2k";
|
||||
|
||||
let prompt: string | null = mergedArgs.prompt;
|
||||
if (!prompt && mergedArgs.promptFiles.length > 0) prompt = await readPromptFromFiles(mergedArgs.promptFiles);
|
||||
if (!prompt) prompt = await readPromptFromStdin();
|
||||
|
||||
if (!prompt) {
|
||||
@@ -295,27 +426,40 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args.imagePath) {
|
||||
if (!mergedArgs.imagePath) {
|
||||
console.error("Error: --image is required");
|
||||
printUsage();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = detectProvider(args);
|
||||
if (mergedArgs.referenceImages.length > 0) {
|
||||
await validateReferenceImages(mergedArgs.referenceImages);
|
||||
}
|
||||
|
||||
const provider = detectProvider(mergedArgs);
|
||||
const providerModule = await loadProviderModule(provider);
|
||||
const model = args.model || providerModule.getDefaultModel();
|
||||
const outputPath = normalizeOutputImagePath(args.imagePath);
|
||||
|
||||
let model = mergedArgs.model;
|
||||
if (!model && extendConfig.default_model) {
|
||||
if (provider === "google") model = extendConfig.default_model.google ?? null;
|
||||
if (provider === "openai") model = extendConfig.default_model.openai ?? null;
|
||||
if (provider === "dashscope") model = extendConfig.default_model.dashscope ?? null;
|
||||
if (provider === "replicate") model = extendConfig.default_model.replicate ?? null;
|
||||
}
|
||||
model = model || providerModule.getDefaultModel();
|
||||
|
||||
const outputPath = normalizeOutputImagePath(mergedArgs.imagePath);
|
||||
|
||||
let imageData: Uint8Array;
|
||||
let retried = false;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
imageData = await providerModule.generateImage(prompt, model, args);
|
||||
imageData = await providerModule.generateImage(prompt, model, mergedArgs);
|
||||
break;
|
||||
} catch (e) {
|
||||
if (!retried) {
|
||||
if (!retried && isRetryableGenerationError(e)) {
|
||||
retried = true;
|
||||
console.error("Generation failed, retrying...");
|
||||
continue;
|
||||
@@ -328,7 +472,7 @@ async function main(): Promise<void> {
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(outputPath, imageData);
|
||||
|
||||
if (args.json) {
|
||||
if (mergedArgs.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
|
||||
@@ -58,7 +58,9 @@ export async function generateImage(
|
||||
if (!apiKey) throw new Error("DASHSCOPE_API_KEY is required");
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
console.error("Warning: Reference images not yet supported with DashScope, ignoring.");
|
||||
throw new Error(
|
||||
"Reference images are not supported with DashScope provider in baoyu-image-gen. Use --provider google with a Gemini multimodal model."
|
||||
);
|
||||
}
|
||||
|
||||
const size = args.size ? normalizeSize(args.size) : getSizeFromAspectRatio(args.aspectRatio, args.quality);
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { execSync } from "node:child_process";
|
||||
import type { CliArgs } from "../types";
|
||||
|
||||
const GOOGLE_MULTIMODAL_MODELS = ["gemini-3-pro-image-preview", "gemini-3-flash-preview"];
|
||||
const GOOGLE_IMAGEN_MODELS = ["imagen-3.0-generate-002", "imagen-3.0-generate-001"];
|
||||
const GOOGLE_MULTIMODAL_MODELS = [
|
||||
"gemini-3-pro-image-preview",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3.1-flash-image-preview",
|
||||
];
|
||||
const GOOGLE_IMAGEN_MODELS = [
|
||||
"imagen-3.0-generate-002",
|
||||
"imagen-3.0-generate-001",
|
||||
];
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.GOOGLE_IMAGE_MODEL || "gemini-3-pro-image-preview";
|
||||
@@ -33,7 +41,8 @@ function getGoogleImageSize(args: CliArgs): "1K" | "2K" | "4K" {
|
||||
}
|
||||
|
||||
function getGoogleBaseUrl(): string {
|
||||
const base = process.env.GOOGLE_BASE_URL || "https://generativelanguage.googleapis.com";
|
||||
const base =
|
||||
process.env.GOOGLE_BASE_URL || "https://generativelanguage.googleapis.com";
|
||||
return base.replace(/\/+$/g, "");
|
||||
}
|
||||
|
||||
@@ -49,11 +58,46 @@ function toModelPath(model: string): string {
|
||||
return `models/${modelId}`;
|
||||
}
|
||||
|
||||
async function postGoogleJson<T>(pathname: string, body: unknown): Promise<T> {
|
||||
const apiKey = getGoogleApiKey();
|
||||
if (!apiKey) throw new Error("GOOGLE_API_KEY or GEMINI_API_KEY is required");
|
||||
function getHttpProxy(): string | null {
|
||||
return (
|
||||
process.env.https_proxy ||
|
||||
process.env.HTTPS_PROXY ||
|
||||
process.env.http_proxy ||
|
||||
process.env.HTTP_PROXY ||
|
||||
process.env.ALL_PROXY ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
const res = await fetch(buildGoogleUrl(pathname), {
|
||||
async function postGoogleJsonViaCurl<T>(
|
||||
url: string,
|
||||
apiKey: string,
|
||||
body: unknown,
|
||||
): Promise<T> {
|
||||
const proxy = getHttpProxy();
|
||||
const bodyStr = JSON.stringify(body);
|
||||
const proxyArgs = proxy ? `-x "${proxy}"` : "";
|
||||
|
||||
const result = execSync(
|
||||
`curl -s --connect-timeout 30 --max-time 300 ${proxyArgs} "${url}" -H "Content-Type: application/json" -H "x-goog-api-key: ${apiKey}" -d @-`,
|
||||
{ input: bodyStr, maxBuffer: 100 * 1024 * 1024, timeout: 310000 },
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.toString()) as any;
|
||||
if (parsed.error) {
|
||||
throw new Error(
|
||||
`Google API error (${parsed.error.code}): ${parsed.error.message}`,
|
||||
);
|
||||
}
|
||||
return parsed as T;
|
||||
}
|
||||
|
||||
async function postGoogleJsonViaFetch<T>(
|
||||
url: string,
|
||||
apiKey: string,
|
||||
body: unknown,
|
||||
): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -70,7 +114,30 @@ async function postGoogleJson<T>(pathname: string, body: unknown): Promise<T> {
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
function buildPromptWithAspect(prompt: string, ar: string | null, quality: CliArgs["quality"]): string {
|
||||
async function postGoogleJson<T>(pathname: string, body: unknown): Promise<T> {
|
||||
const apiKey = getGoogleApiKey();
|
||||
if (!apiKey) throw new Error("GOOGLE_API_KEY or GEMINI_API_KEY is required");
|
||||
|
||||
const url = buildGoogleUrl(pathname);
|
||||
const proxy = getHttpProxy();
|
||||
|
||||
// When an HTTP proxy is detected, use curl instead of fetch.
|
||||
// Bun's fetch has a known issue where long-lived connections through
|
||||
// HTTP proxies get their sockets closed unexpectedly, causing image
|
||||
// generation requests to fail with "socket connection was closed
|
||||
// unexpectedly". Using curl as the HTTP client works around this.
|
||||
if (proxy) {
|
||||
return postGoogleJsonViaCurl<T>(url, apiKey, body);
|
||||
}
|
||||
|
||||
return postGoogleJsonViaFetch<T>(url, apiKey, body);
|
||||
}
|
||||
|
||||
function buildPromptWithAspect(
|
||||
prompt: string,
|
||||
ar: string | null,
|
||||
quality: CliArgs["quality"],
|
||||
): string {
|
||||
let result = prompt;
|
||||
if (ar) {
|
||||
result += ` Aspect ratio: ${ar}.`;
|
||||
@@ -86,7 +153,9 @@ function addAspectRatioToPrompt(prompt: string, ar: string | null): string {
|
||||
return `${prompt} Aspect ratio: ${ar}.`;
|
||||
}
|
||||
|
||||
async function readImageAsBase64(p: string): Promise<{ data: string; mimeType: string }> {
|
||||
async function readImageAsBase64(
|
||||
p: string,
|
||||
): Promise<{ data: string; mimeType: string }> {
|
||||
const buf = await readFile(p);
|
||||
const ext = path.extname(p).toLowerCase();
|
||||
let mimeType = "image/png";
|
||||
@@ -97,7 +166,9 @@ async function readImageAsBase64(p: string): Promise<{ data: string; mimeType: s
|
||||
}
|
||||
|
||||
function extractInlineImageData(response: {
|
||||
candidates?: Array<{ content?: { parts?: Array<{ inlineData?: { data?: string } }> } }>;
|
||||
candidates?: Array<{
|
||||
content?: { parts?: Array<{ inlineData?: { data?: string } }> };
|
||||
}>;
|
||||
}): string | null {
|
||||
for (const candidate of response.candidates || []) {
|
||||
for (const part of candidate.content?.parts || []) {
|
||||
@@ -112,16 +183,21 @@ function extractPredictedImageData(response: {
|
||||
predictions?: Array<any>;
|
||||
generatedImages?: Array<any>;
|
||||
}): string | null {
|
||||
const candidates = [...(response.predictions || []), ...(response.generatedImages || [])];
|
||||
const candidates = [
|
||||
...(response.predictions || []),
|
||||
...(response.generatedImages || []),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate || typeof candidate !== "object") continue;
|
||||
if (typeof candidate.imageBytes === "string") return candidate.imageBytes;
|
||||
if (typeof candidate.bytesBase64Encoded === "string") return candidate.bytesBase64Encoded;
|
||||
if (typeof candidate.bytesBase64Encoded === "string")
|
||||
return candidate.bytesBase64Encoded;
|
||||
if (typeof candidate.data === "string") return candidate.data;
|
||||
const image = candidate.image;
|
||||
if (image && typeof image === "object") {
|
||||
if (typeof image.imageBytes === "string") return image.imageBytes;
|
||||
if (typeof image.bytesBase64Encoded === "string") return image.bytesBase64Encoded;
|
||||
if (typeof image.bytesBase64Encoded === "string")
|
||||
return image.bytesBase64Encoded;
|
||||
if (typeof image.data === "string") return image.data;
|
||||
}
|
||||
}
|
||||
@@ -131,10 +207,13 @@ function extractPredictedImageData(response: {
|
||||
async function generateWithGemini(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs
|
||||
args: CliArgs,
|
||||
): Promise<Uint8Array> {
|
||||
const promptWithAspect = addAspectRatioToPrompt(prompt, args.aspectRatio);
|
||||
const parts: Array<{ text?: string; inlineData?: { data: string; mimeType: string } }> = [];
|
||||
const parts: Array<{
|
||||
text?: string;
|
||||
inlineData?: { data: string; mimeType: string };
|
||||
}> = [];
|
||||
for (const refPath of args.referenceImages) {
|
||||
const { data, mimeType } = await readImageAsBase64(refPath);
|
||||
parts.push({ inlineData: { data, mimeType } });
|
||||
@@ -147,7 +226,9 @@ async function generateWithGemini(
|
||||
|
||||
console.log("Generating image with Gemini...", imageConfig);
|
||||
const response = await postGoogleJson<{
|
||||
candidates?: Array<{ content?: { parts?: Array<{ inlineData?: { data?: string } }> } }>;
|
||||
candidates?: Array<{
|
||||
content?: { parts?: Array<{ inlineData?: { data?: string } }> };
|
||||
}>;
|
||||
}>(`${toModelPath(model)}:generateContent`, {
|
||||
contents: [
|
||||
{
|
||||
@@ -171,12 +252,18 @@ async function generateWithGemini(
|
||||
async function generateWithImagen(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs
|
||||
args: CliArgs,
|
||||
): Promise<Uint8Array> {
|
||||
const fullPrompt = buildPromptWithAspect(prompt, args.aspectRatio, args.quality);
|
||||
const fullPrompt = buildPromptWithAspect(
|
||||
prompt,
|
||||
args.aspectRatio,
|
||||
args.quality,
|
||||
);
|
||||
const imageSize = getGoogleImageSize(args);
|
||||
if (imageSize === "4K") {
|
||||
console.error("Warning: Imagen models do not support 4K imageSize, using 2K instead.");
|
||||
console.error(
|
||||
"Warning: Imagen models do not support 4K imageSize, using 2K instead.",
|
||||
);
|
||||
}
|
||||
|
||||
const parameters: Record<string, unknown> = {
|
||||
@@ -212,17 +299,21 @@ async function generateWithImagen(
|
||||
export async function generateImage(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs
|
||||
args: CliArgs,
|
||||
): Promise<Uint8Array> {
|
||||
if (isGoogleImagen(model)) {
|
||||
if (args.referenceImages.length > 0) {
|
||||
console.error("Warning: Reference images not supported with Imagen models, ignoring.");
|
||||
throw new Error(
|
||||
"Reference images are not supported with Imagen models. Use gemini-3-pro-image-preview, gemini-3-flash-preview, or gemini-3.1-flash-image-preview.",
|
||||
);
|
||||
}
|
||||
return generateWithImagen(prompt, model, args);
|
||||
}
|
||||
|
||||
if (!isGoogleMultimodal(model) && args.referenceImages.length > 0) {
|
||||
console.error("Warning: Reference images are only supported with Gemini multimodal models.");
|
||||
throw new Error(
|
||||
"Reference images are only supported with Gemini multimodal models. Use gemini-3-pro-image-preview, gemini-3-flash-preview, or gemini-3.1-flash-image-preview.",
|
||||
);
|
||||
}
|
||||
|
||||
return generateWithGemini(prompt, model, args);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { CliArgs } from "../types";
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.OPENAI_IMAGE_MODEL || "gpt-image-1.5";
|
||||
}
|
||||
|
||||
type OpenAIImageResponse = { data: Array<{ url?: string; b64_json?: string }> };
|
||||
|
||||
function parseAspectRatio(ar: string): { width: number; height: number } | null {
|
||||
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
||||
if (!match) return null;
|
||||
@@ -66,20 +70,70 @@ export async function generateImage(
|
||||
|
||||
if (!apiKey) throw new Error("OPENAI_API_KEY is required");
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
console.error("Warning: Reference images not supported with OpenAI, ignoring.");
|
||||
if (process.env.OPENAI_IMAGE_USE_CHAT === "true") {
|
||||
return generateWithChatCompletions(baseURL, apiKey, prompt, model);
|
||||
}
|
||||
|
||||
const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
|
||||
|
||||
const body: Record<string, any> = {
|
||||
model,
|
||||
prompt,
|
||||
size,
|
||||
};
|
||||
if (args.referenceImages.length > 0) {
|
||||
if (model.includes("dall-e-2") || model.includes("dall-e-3")) {
|
||||
throw new Error(
|
||||
"Reference images with OpenAI in this skill require GPT Image models. Use --model gpt-image-1.5 (or another gpt-image model)."
|
||||
);
|
||||
}
|
||||
return generateWithOpenAIEdits(baseURL, apiKey, prompt, model, size, args.referenceImages, args.quality);
|
||||
}
|
||||
|
||||
return generateWithOpenAIGenerations(baseURL, apiKey, prompt, model, size, args.quality);
|
||||
}
|
||||
|
||||
async function generateWithChatCompletions(
|
||||
baseURL: string,
|
||||
apiKey: string,
|
||||
prompt: string,
|
||||
model: string
|
||||
): Promise<Uint8Array> {
|
||||
const res = await fetch(`${baseURL}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`OpenAI API error: ${err}`);
|
||||
}
|
||||
|
||||
const result = (await res.json()) as { choices: Array<{ message: { content: string } }> };
|
||||
const content = result.choices[0]?.message?.content ?? "";
|
||||
|
||||
const match = content.match(/data:image\/[^;]+;base64,([A-Za-z0-9+/=]+)/);
|
||||
if (match) {
|
||||
return Uint8Array.from(Buffer.from(match[1]!, "base64"));
|
||||
}
|
||||
|
||||
throw new Error("No image found in chat completions response");
|
||||
}
|
||||
|
||||
async function generateWithOpenAIGenerations(
|
||||
baseURL: string,
|
||||
apiKey: string,
|
||||
prompt: string,
|
||||
model: string,
|
||||
size: string,
|
||||
quality: CliArgs["quality"]
|
||||
): Promise<Uint8Array> {
|
||||
const body: Record<string, any> = { model, prompt, size };
|
||||
|
||||
if (model.includes("dall-e-3")) {
|
||||
body.quality = args.quality === "2k" ? "hd" : "standard";
|
||||
body.quality = quality === "2k" ? "hd" : "standard";
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseURL}/images/generations`, {
|
||||
@@ -96,7 +150,62 @@ export async function generateImage(
|
||||
throw new Error(`OpenAI API error: ${err}`);
|
||||
}
|
||||
|
||||
const result = (await res.json()) as { data: Array<{ url?: string; b64_json?: string }> };
|
||||
const result = (await res.json()) as OpenAIImageResponse;
|
||||
return extractImageFromResponse(result);
|
||||
}
|
||||
|
||||
async function generateWithOpenAIEdits(
|
||||
baseURL: string,
|
||||
apiKey: string,
|
||||
prompt: string,
|
||||
model: string,
|
||||
size: string,
|
||||
referenceImages: string[],
|
||||
quality: CliArgs["quality"]
|
||||
): Promise<Uint8Array> {
|
||||
const form = new FormData();
|
||||
form.append("model", model);
|
||||
form.append("prompt", prompt);
|
||||
form.append("size", size);
|
||||
|
||||
if (model.includes("gpt-image")) {
|
||||
form.append("quality", quality === "2k" ? "high" : "medium");
|
||||
}
|
||||
|
||||
for (const refPath of referenceImages) {
|
||||
const bytes = await readFile(refPath);
|
||||
const filename = path.basename(refPath);
|
||||
const mimeType = getMimeType(filename);
|
||||
const blob = new Blob([bytes], { type: mimeType });
|
||||
form.append("image[]", blob, filename);
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseURL}/images/edits`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`OpenAI edits API error: ${err}`);
|
||||
}
|
||||
|
||||
const result = (await res.json()) as OpenAIImageResponse;
|
||||
return extractImageFromResponse(result);
|
||||
}
|
||||
|
||||
function getMimeType(filename: string): string {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
||||
if (ext === ".webp") return "image/webp";
|
||||
if (ext === ".gif") return "image/gif";
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
async function extractImageFromResponse(result: OpenAIImageResponse): Promise<Uint8Array> {
|
||||
const img = result.data[0];
|
||||
|
||||
if (img?.b64_json) {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { CliArgs } from "../types";
|
||||
|
||||
const DEFAULT_MODEL = "google/nano-banana-pro";
|
||||
const SYNC_WAIT_SECONDS = 60;
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const MAX_POLL_MS = 300_000;
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.REPLICATE_IMAGE_MODEL || DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
function getApiToken(): string | null {
|
||||
return process.env.REPLICATE_API_TOKEN || null;
|
||||
}
|
||||
|
||||
function getBaseUrl(): string {
|
||||
const base = process.env.REPLICATE_BASE_URL || "https://api.replicate.com";
|
||||
return base.replace(/\/+$/g, "");
|
||||
}
|
||||
|
||||
function parseModelId(model: string): { owner: string; name: string; version: string | null } {
|
||||
const [ownerName, version] = model.split(":");
|
||||
const parts = ownerName!.split("/");
|
||||
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
||||
throw new Error(
|
||||
`Invalid Replicate model format: "${model}". Expected "owner/name" or "owner/name:version".`
|
||||
);
|
||||
}
|
||||
return { owner: parts[0], name: parts[1], version: version || null };
|
||||
}
|
||||
|
||||
function buildInput(prompt: string, args: CliArgs, referenceImages: string[]): Record<string, unknown> {
|
||||
const input: Record<string, unknown> = { prompt };
|
||||
|
||||
if (args.aspectRatio) {
|
||||
input.aspect_ratio = args.aspectRatio;
|
||||
}
|
||||
|
||||
if (args.n > 1) {
|
||||
input.number_of_images = args.n;
|
||||
}
|
||||
|
||||
input.output_format = "png";
|
||||
|
||||
if (referenceImages.length > 0) {
|
||||
if (referenceImages.length === 1) {
|
||||
input.image = referenceImages[0];
|
||||
} else {
|
||||
for (let i = 0; i < referenceImages.length; i++) {
|
||||
input[`image${i > 0 ? i + 1 : ""}`] = referenceImages[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
async function readImageAsDataUrl(p: string): Promise<string> {
|
||||
const buf = await readFile(p);
|
||||
const ext = path.extname(p).toLowerCase();
|
||||
let mimeType = "image/png";
|
||||
if (ext === ".jpg" || ext === ".jpeg") mimeType = "image/jpeg";
|
||||
else if (ext === ".gif") mimeType = "image/gif";
|
||||
else if (ext === ".webp") mimeType = "image/webp";
|
||||
return `data:${mimeType};base64,${buf.toString("base64")}`;
|
||||
}
|
||||
|
||||
type PredictionResponse = {
|
||||
id: string;
|
||||
status: string;
|
||||
output: unknown;
|
||||
error: string | null;
|
||||
urls?: { get?: string };
|
||||
};
|
||||
|
||||
async function createPrediction(
|
||||
apiToken: string,
|
||||
model: { owner: string; name: string; version: string | null },
|
||||
input: Record<string, unknown>,
|
||||
sync: boolean
|
||||
): Promise<PredictionResponse> {
|
||||
const baseUrl = getBaseUrl();
|
||||
|
||||
let url: string;
|
||||
const body: Record<string, unknown> = { input };
|
||||
|
||||
if (model.version) {
|
||||
url = `${baseUrl}/v1/predictions`;
|
||||
body.version = model.version;
|
||||
} else {
|
||||
url = `${baseUrl}/v1/models/${model.owner}/${model.name}/predictions`;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (sync) {
|
||||
headers["Prefer"] = `wait=${SYNC_WAIT_SECONDS}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Replicate API error (${res.status}): ${err}`);
|
||||
}
|
||||
|
||||
return (await res.json()) as PredictionResponse;
|
||||
}
|
||||
|
||||
async function pollPrediction(apiToken: string, getUrl: string): Promise<PredictionResponse> {
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < MAX_POLL_MS) {
|
||||
const res = await fetch(getUrl, {
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Replicate poll error (${res.status}): ${err}`);
|
||||
}
|
||||
|
||||
const prediction = (await res.json()) as PredictionResponse;
|
||||
|
||||
if (prediction.status === "succeeded") return prediction;
|
||||
if (prediction.status === "failed" || prediction.status === "canceled") {
|
||||
throw new Error(`Replicate prediction ${prediction.status}: ${prediction.error || "unknown error"}`);
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
||||
}
|
||||
|
||||
throw new Error(`Replicate prediction timed out after ${MAX_POLL_MS / 1000}s`);
|
||||
}
|
||||
|
||||
function extractOutputUrl(prediction: PredictionResponse): string {
|
||||
const output = prediction.output;
|
||||
|
||||
if (typeof output === "string") return output;
|
||||
|
||||
if (Array.isArray(output)) {
|
||||
const first = output[0];
|
||||
if (typeof first === "string") return first;
|
||||
}
|
||||
|
||||
if (output && typeof output === "object" && "url" in output) {
|
||||
const url = (output as Record<string, unknown>).url;
|
||||
if (typeof url === "string") return url;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected Replicate output format: ${JSON.stringify(output)}`);
|
||||
}
|
||||
|
||||
async function downloadImage(url: string): Promise<Uint8Array> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to download image from Replicate: ${res.status}`);
|
||||
const buf = await res.arrayBuffer();
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
export async function generateImage(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs
|
||||
): Promise<Uint8Array> {
|
||||
const apiToken = getApiToken();
|
||||
if (!apiToken) throw new Error("REPLICATE_API_TOKEN is required. Get one at https://replicate.com/account/api-tokens");
|
||||
|
||||
const parsedModel = parseModelId(model);
|
||||
|
||||
const refDataUrls: string[] = [];
|
||||
for (const refPath of args.referenceImages) {
|
||||
refDataUrls.push(await readImageAsDataUrl(refPath));
|
||||
}
|
||||
|
||||
const input = buildInput(prompt, args, refDataUrls);
|
||||
|
||||
console.log(`Generating image with Replicate (${model})...`);
|
||||
|
||||
let prediction = await createPrediction(apiToken, parsedModel, input, true);
|
||||
|
||||
if (prediction.status !== "succeeded") {
|
||||
if (!prediction.urls?.get) {
|
||||
throw new Error("Replicate prediction did not return a poll URL");
|
||||
}
|
||||
console.log("Waiting for prediction to complete...");
|
||||
prediction = await pollPrediction(apiToken, prediction.urls.get);
|
||||
}
|
||||
|
||||
console.log("Generation completed.");
|
||||
|
||||
const outputUrl = extractOutputUrl(prediction);
|
||||
return downloadImage(outputUrl);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type Provider = "google" | "openai" | "dashscope";
|
||||
export type Provider = "google" | "openai" | "dashscope" | "replicate";
|
||||
export type Quality = "normal" | "2k";
|
||||
|
||||
export type CliArgs = {
|
||||
@@ -9,10 +9,24 @@ export type CliArgs = {
|
||||
model: string | null;
|
||||
aspectRatio: string | null;
|
||||
size: string | null;
|
||||
quality: Quality;
|
||||
quality: Quality | null;
|
||||
imageSize: string | null;
|
||||
referenceImages: string[];
|
||||
n: number;
|
||||
json: boolean;
|
||||
help: boolean;
|
||||
};
|
||||
|
||||
export type ExtendConfig = {
|
||||
version: number;
|
||||
default_provider: Provider | null;
|
||||
default_quality: Quality | null;
|
||||
default_aspect_ratio: string | null;
|
||||
default_image_size: "1K" | "2K" | "4K" | null;
|
||||
default_model: {
|
||||
google: string | null;
|
||||
openai: string | null;
|
||||
dashscope: string | null;
|
||||
replicate: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: baoyu-infographic
|
||||
description: Generates professional infographics with 20 layout types and 17 visual styles. Analyzes content, recommends layout×style combinations, and generates publication-ready infographics. Use when user asks to create "infographic", "信息图", "visual summary", or "可视化".
|
||||
description: Generates professional infographics with 21 layout types and 20 visual styles. Analyzes content, recommends layout×style combinations, and generates publication-ready infographics. Use when user asks to create "infographic", "信息图", "visual summary", "可视化", or "高密度信息大图".
|
||||
---
|
||||
|
||||
# Infographic Generator
|
||||
@@ -20,8 +20,8 @@ Two dimensions: **layout** (information structure) × **style** (visual aestheti
|
||||
|
||||
| Option | Values |
|
||||
|--------|--------|
|
||||
| `--layout` | 20 options (see Layout Gallery), default: bento-grid |
|
||||
| `--style` | 17 options (see Style Gallery), default: craft-handmade |
|
||||
| `--layout` | 21 options (see Layout Gallery), default: bento-grid |
|
||||
| `--style` | 20 options (see Style Gallery), default: craft-handmade |
|
||||
| `--aspect` | landscape (16:9), portrait (9:16), square (1:1) |
|
||||
| `--lang` | en, zh, ja, etc. |
|
||||
|
||||
@@ -49,6 +49,7 @@ Two dimensions: **layout** (information structure) × **style** (visual aestheti
|
||||
| `venn-diagram` | Overlapping concepts |
|
||||
| `winding-roadmap` | Journey, milestones |
|
||||
| `circular-flow` | Cycles, recurring processes |
|
||||
| `dense-modules` | High-density modules, data-rich guides |
|
||||
|
||||
Full definitions: `references/layouts/<layout>.md`
|
||||
|
||||
@@ -73,6 +74,9 @@ Full definitions: `references/layouts/<layout>.md`
|
||||
| `ikea-manual` | Minimal line art |
|
||||
| `knolling` | Organized flat-lay |
|
||||
| `lego-brick` | Toy brick construction |
|
||||
| `pop-laboratory` | Blueprint grid, coordinate markers, lab precision |
|
||||
| `morandi-journal` | Hand-drawn doodle, warm Morandi tones |
|
||||
| `retro-pop-grid` | 1970s retro pop art, Swiss grid, thick outlines |
|
||||
|
||||
Full definitions: `references/styles/<style>.md`
|
||||
|
||||
@@ -92,9 +96,23 @@ Full definitions: `references/styles/<style>.md`
|
||||
| Educational | `bento-grid` + `chalkboard` |
|
||||
| Journey | `winding-roadmap` + `storybook-watercolor` |
|
||||
| Categories | `periodic-table` + `bold-graphic` |
|
||||
| Product Guide | `dense-modules` + `morandi-journal` |
|
||||
| Technical Guide | `dense-modules` + `pop-laboratory` |
|
||||
| Trendy Guide | `dense-modules` + `retro-pop-grid` |
|
||||
|
||||
Default: `bento-grid` + `craft-handmade`
|
||||
|
||||
## Keyword Shortcuts
|
||||
|
||||
When user input contains these keywords, **auto-select** the associated layout and offer associated styles as top recommendations in Step 3. Skip content-based layout inference for matched keywords.
|
||||
|
||||
If a shortcut has **Prompt Notes**, append them to the generated prompt (Step 5) as additional style instructions.
|
||||
|
||||
| User Keyword | Layout | Recommended Styles | Default Aspect | Prompt Notes |
|
||||
|--------------|--------|--------------------|----------------|--------------|
|
||||
| 高密度信息大图 / high-density-info | `dense-modules` | `morandi-journal`, `pop-laboratory`, `retro-pop-grid` | portrait | — |
|
||||
| 信息图 / infographic | `bento-grid` | `craft-handmade` | landscape | Minimalist: clean canvas, ample whitespace, no complex background textures. Simple cartoon elements and icons only. |
|
||||
|
||||
## Output Structure
|
||||
|
||||
```
|
||||
@@ -153,10 +171,12 @@ Schema: `references/config/preferences-schema.md`
|
||||
**1.2 Analyze Content → `analysis.md`**
|
||||
|
||||
1. Save source content (file path or paste → `source.md`)
|
||||
- **Backup rule**: If `source.md` exists, rename to `source-backup-YYYYMMDD-HHMMSS.md`
|
||||
2. Analyze: topic, data type, complexity, tone, audience
|
||||
3. Detect source language and user language
|
||||
4. Extract design instructions from user input
|
||||
5. Save analysis
|
||||
- **Backup rule**: If `analysis.md` exists, rename to `analysis-backup-YYYYMMDD-HHMMSS.md`
|
||||
|
||||
See `references/analysis-framework.md` for detailed format.
|
||||
|
||||
@@ -174,7 +194,9 @@ See `references/structured-content-template.md` for detailed format.
|
||||
|
||||
### Step 3: Recommend Combinations
|
||||
|
||||
Recommend 3-5 layout×style combinations based on:
|
||||
**3.1 Check Keyword Shortcuts first**: If user input matches a keyword from the **Keyword Shortcuts** table, auto-select the associated layout and prioritize associated styles as top recommendations. Skip content-based layout inference.
|
||||
|
||||
**3.2 Otherwise**, recommend 3-5 layout×style combinations based on:
|
||||
- Data structure → matching layout
|
||||
- Content tone → matching style
|
||||
- Audience expectations
|
||||
@@ -182,13 +204,20 @@ Recommend 3-5 layout×style combinations based on:
|
||||
|
||||
### Step 4: Confirm Options
|
||||
|
||||
Present all options in single confirmation:
|
||||
1. **Combination** (always): 3+ options with rationale
|
||||
2. **Aspect** (always): landscape/portrait/square
|
||||
3. **Language** (only if source ≠ user language): which language for text
|
||||
Use **single AskUserQuestion call** with multiple questions to confirm all options together:
|
||||
|
||||
| Question | When | Options |
|
||||
|----------|------|---------|
|
||||
| **Combination** | Always | 3+ layout×style combos with rationale |
|
||||
| **Aspect** | Always | landscape (16:9), portrait (9:16), square (1:1) |
|
||||
| **Language** | Only if source ≠ user language | Language for text content |
|
||||
|
||||
**Important**: Do NOT split into separate AskUserQuestion calls. Combine all applicable questions into one call.
|
||||
|
||||
### Step 5: Generate Prompt → `prompts/infographic.md`
|
||||
|
||||
**Backup rule**: If `prompts/infographic.md` exists, rename to `prompts/infographic-backup-YYYYMMDD-HHMMSS.md`
|
||||
|
||||
Combine:
|
||||
1. Layout definition from `references/layouts/<layout>.md`
|
||||
2. Style definition from `references/styles/<style>.md`
|
||||
@@ -199,8 +228,10 @@ Combine:
|
||||
### Step 6: Generate Image
|
||||
|
||||
1. Select available image generation skill (ask user if multiple)
|
||||
2. Call with prompt file and output path
|
||||
3. On failure, auto-retry once
|
||||
2. **Check for existing file**: Before generating, check if `infographic.png` exists
|
||||
- If exists: Rename to `infographic-backup-YYYYMMDD-HHMMSS.png`
|
||||
3. Call with prompt file and output path
|
||||
4. On failure, auto-retry once
|
||||
|
||||
### Step 7: Output Summary
|
||||
|
||||
@@ -211,8 +242,8 @@ Report: topic, layout, style, aspect, language, output path, files created.
|
||||
- `references/analysis-framework.md` - Analysis methodology
|
||||
- `references/structured-content-template.md` - Content format
|
||||
- `references/base-prompt.md` - Prompt template
|
||||
- `references/layouts/<layout>.md` - 20 layout definitions
|
||||
- `references/styles/<style>.md` - 17 style definitions
|
||||
- `references/layouts/<layout>.md` - 21 layout definitions
|
||||
- `references/styles/<style>.md` - 20 style definitions
|
||||
|
||||
## Extension Support
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ Approach content analysis as a **world-class instructional designer**:
|
||||
| **Cycle/Loop** | Recurring processes, feedback loops | circular-flow | craft-handmade, technical-schematic |
|
||||
| **System/Structure** | Components, architecture, anatomy | structural-breakdown, bento-grid | technical-schematic, ikea-manual |
|
||||
| **Journey/Narrative** | Stories, user flows, milestones | winding-roadmap, story-mountain | storybook-watercolor, comic-strip |
|
||||
| **Overview/Summary** | Multiple topics, feature highlights | bento-grid, periodic-table | chalkboard, bold-graphic |
|
||||
| **Overview/Summary** | Multiple topics, feature highlights | bento-grid, periodic-table, dense-modules | chalkboard, bold-graphic |
|
||||
| **Product/Buying Guide** | Multi-dimension comparisons, specs, pitfalls | dense-modules | morandi-journal, pop-laboratory, retro-pop-grid |
|
||||
|
||||
### 2. Learning Objective Identification
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# dense-modules
|
||||
|
||||
High-density modular layout with 6-7 typed information modules packed with concrete data.
|
||||
|
||||
## Structure
|
||||
|
||||
- 6-7 distinct modules per image, each serving a specific information function
|
||||
- Every module contains concrete data: brand names, numbers, percentages, parameters
|
||||
- Minimal whitespace—compact spacing prioritized over breathing room
|
||||
- Smaller text acceptable to maximize information density
|
||||
- Each module identified by coordinate label or section marker (e.g., MOD-1, SEC-A)
|
||||
|
||||
## Module Archetypes
|
||||
|
||||
| Module | Purpose | Content Requirements |
|
||||
|--------|---------|---------------------|
|
||||
| **Brand/Selection Array** | Grid of options with recommendations | 4-8 items with icons, names, brief descriptions; highlight "best choice" |
|
||||
| **Specification Scale** | Quality/measurement gauge | 3-5 levels with precise numerical increments, quality indicators (emoji faces, checkmarks) |
|
||||
| **Deep Dive/Detail** | Technical breakdown of key item | Zoom-in callouts, internal components, cross-section or exploded view |
|
||||
| **Scenario Comparison** | Side-by-side use cases | 3-6 scenarios with specific recommendations and data per scenario |
|
||||
| **Identification Tips** | How-to checklist | 3-5 inspection methods: look/test/check/ask format |
|
||||
| **Warning/Pitfall Zone** | Critical mistakes to avoid | 3-5 pitfalls with consequences, 1-2 correct approaches; high visual contrast |
|
||||
| **Quick Reference** | Compact summary | Dense table, one-line summaries, decision flowchart, or key takeaways |
|
||||
|
||||
## Variants
|
||||
|
||||
| Variant | Focus | Visual Emphasis |
|
||||
|---------|-------|-----------------|
|
||||
| **Coordinate-labeled** | Precision and systematicity | Each module has alphanumeric coordinate (A-01, B-05, C-12), ruler/axis markers |
|
||||
| **Grid-cell** | Order and structure | Modules in strict rectangular cells divided by thick lines, Swiss grid feel |
|
||||
| **Free-flowing** | Organic density | Magazine-style layout with dotted frames, varying module sizes, connected by arrows |
|
||||
|
||||
## Best For
|
||||
|
||||
- Product selection guides and buying guides
|
||||
- Multi-dimensional comparison content
|
||||
- Data-rich educational materials
|
||||
- "Avoid pitfalls" / "complete guide" formats
|
||||
- Content targeting platforms like Xiaohongshu with high-density visual requirements
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Module boundary markers (thick lines, dotted frames, or coordinate grids)
|
||||
- Quality indicators per module (emoji faces, checkmarks, crosses, crowns)
|
||||
- Data callout boxes with highlighted numbers
|
||||
- Comparison arrows and progression indicators
|
||||
- Warning/alert visual markers for pitfall modules
|
||||
- Metadata in corners (page numbers, timestamps, small barcodes)
|
||||
|
||||
## Text Placement
|
||||
|
||||
- Main title at top, prominent and impactful
|
||||
- Subtitle with module count ("X大维度全面解析...")
|
||||
- Module headers inside colored badges or labeled frames
|
||||
- Body text compact, multiple columns within modules
|
||||
- Numbers highlighted with accent colors, slightly larger than body text
|
||||
|
||||
## Information Density Rules
|
||||
|
||||
- Every corner should contain useful information or metadata
|
||||
- No decorative-only empty space
|
||||
- Text size may be reduced to fit more content—information over font size
|
||||
- Each module must have specific data points, not generic descriptions
|
||||
- Balance between density and readability: dense but organized
|
||||
|
||||
## Recommended Pairings
|
||||
|
||||
- `pop-laboratory`: Technical precision with coordinate markers and blueprint grid
|
||||
- `morandi-journal`: Hand-drawn warmth with doodle illustrations and organic frames
|
||||
- `retro-pop-grid`: 1970s pop art with strict grid cells and bold contrast
|
||||
- `corporate-memphis`: Clean business feel for product comparisons
|
||||
- `technical-schematic`: Engineering precision for technical product guides
|
||||
@@ -0,0 +1,60 @@
|
||||
# morandi-journal
|
||||
|
||||
Hand-drawn doodle illustration with warm Morandi color tones and cozy bullet journal aesthetic.
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Background: Warm cream/beige with subtle paper texture (#F5F0E6)
|
||||
- Primary: Muted teal/sage green (#7BA3A8) for headers and frames
|
||||
- Secondary: Warm terracotta/orange (#D4956A) for highlights and numbers
|
||||
- Line art: Dark charcoal brown (#4A4540)
|
||||
- Soft highlights: Pale yellow (#F5E6C8)
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Hand-drawn doodle illustrations with organic, slightly imperfect ink lines
|
||||
- Washi tape strip decorations (diagonal stripes pattern, beige and brown)
|
||||
- Rounded card containers for brand/option items
|
||||
- Hand-drawn rulers, scales, and progress bars with emoji quality indicators
|
||||
- Smiley/frowny faces as quality markers (😊✓ 😐 ☹️✗)
|
||||
- Dotted line frames around sections
|
||||
- Connecting arrows and dotted lines between modules
|
||||
- Corner decorations: tiny houses, stars, sparkles, clouds
|
||||
- Wavy line dividers between sections
|
||||
- Callout bubbles for tips
|
||||
- Magnifying glass icons for identification tips
|
||||
- Thumbs up/down icons (hand-drawn style)
|
||||
|
||||
## Variants
|
||||
|
||||
| Variant | Focus | Visual Emphasis |
|
||||
|---------|-------|-----------------|
|
||||
| **Cozy journal** | Maximum warmth | More washi tape, stickers, decorative doodles |
|
||||
| **Clean sketch** | Readability | Cleaner lines, less decoration, more structured |
|
||||
|
||||
## Typography
|
||||
|
||||
- Main title: Bold hand-lettered calligraphy style with decorative flourishes
|
||||
- Module headers: Clean handwritten text in white on dark teal rounded badge (#6B9080)
|
||||
- Body text: Neat handwritten print style, easy to read
|
||||
- Numbers: Highlighted in terracotta (#D4956A), slightly larger than body
|
||||
|
||||
## Style Enforcement
|
||||
|
||||
- All imagery must maintain hand-drawn/doodle aesthetic—no digital precision
|
||||
- Organic, slightly imperfect shapes throughout
|
||||
- Sketch-like quality with visible line weight variations
|
||||
- Warm and cozy journal feel, not clinical or corporate
|
||||
|
||||
## Avoid
|
||||
|
||||
- Flat vector icons or emoji
|
||||
- Clean geometric shapes
|
||||
- Stock illustration style
|
||||
- Strict grid layout
|
||||
- Pure white background
|
||||
- Digital/corporate look
|
||||
|
||||
## Best For
|
||||
|
||||
Product selection guides, lifestyle content, educational overviews, consumer-facing comparison content, Xiaohongshu-style posts
|
||||
@@ -0,0 +1,48 @@
|
||||
# pop-laboratory
|
||||
|
||||
Lab manual precision meets pop art color impact—coordinate systems, technical diagrams, and fluorescent accents on blueprint grid.
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Background: Professional grayish-white with faint blueprint grid texture (#F2F2F2)
|
||||
- Primary: Muted teal/sage green (#B8D8BE) for major functional blocks and data zones
|
||||
- High-alert accent: Vibrant fluorescent pink (#E91E63) strictly for warnings, critical data, or "winner" highlights
|
||||
- Marker highlights: Vivid lemon yellow (#FFF200) as translucent highlighter effect for keywords
|
||||
- Line art: Ultra-fine charcoal brown (#2D2926) for technical grids, coordinates, and hairlines
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Coordinate-style labels on every module (e.g., R-20, G-02, SEC-08)
|
||||
- Technical diagrams: exploded views, cross-sections with anchor points, architectural skeletal lines
|
||||
- Vertical/horizontal rulers with precise markers (0.5mm, 1.8mm, 45°)
|
||||
- "Marker-over-print" effect: color blocks slightly offset from text, postmodern print feel
|
||||
- Cross-hair targets, mathematical symbols (Σ, Δ, ∞), directional arrows (X/Y axis)
|
||||
- Microscopic detail annotations alongside macroscopic bold headers
|
||||
- Corner metadata: tiny barcodes, timestamps, technical parameters
|
||||
- High contrast between massive bold headers and tiny 8pt-style annotations
|
||||
|
||||
## Typography
|
||||
|
||||
- Headers: Bold brutalist characters, high visual impact
|
||||
- Body: Professional sans-serif or crisp technical print
|
||||
- Numbers: Large, highlighted with yellow or blue to stand out
|
||||
- Annotations: Ultra-crisp, small technical labels
|
||||
|
||||
## Style Enforcement
|
||||
|
||||
- Strictly systematic color usage: only teal, pink, yellow, charcoal—no rainbow palette
|
||||
- Sufficient fine grid lines and coordinate annotations throughout
|
||||
- Maintain tension between large impactful headers and small precise parameters
|
||||
- Lab manual aesthetic: mix of microscopic details and macroscopic data
|
||||
|
||||
## Avoid
|
||||
|
||||
- Cute or cartoonish doodles
|
||||
- Soft pastels or generic textures
|
||||
- Empty white space
|
||||
- Flat vector stock icons
|
||||
- Organic or hand-drawn imperfections
|
||||
|
||||
## Best For
|
||||
|
||||
Technical product guides, specification comparisons, precision-focused data visualization, engineering-adjacent content
|
||||
@@ -0,0 +1,47 @@
|
||||
# retro-pop-grid
|
||||
|
||||
1970s retro pop art with strict Swiss international grid, thick black outlines, and flat color blocks.
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Background: Warm vintage cream/beige (#F5F0E6)
|
||||
- Flat accents: Salmon pink, sky blue, mustard yellow, mint green—all muted retro tones
|
||||
- Contrast blocks: Solid pure black (#000000) and solid pure white (#FFFFFF) used strategically for extreme contrast
|
||||
- Line art and outlines: Solid thick black
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Uniform thick black outlines on all illustrations, text boxes, and grid dividers
|
||||
- Pure 2D flat vector aesthetic with subtle screen print texture
|
||||
- Strict Swiss international grid: poster divided into square and rectangular cells by thick black lines
|
||||
- Black-background cells with white text for warnings or key categories (inverted contrast)
|
||||
- Geometric fill patterns in empty cells: checkerboards, diagonal lines, dots
|
||||
- Flat abstract symbols, warning signs, keyholes, stars, arrows
|
||||
- Vintage comic-style smiley/frowny faces for quality indicators
|
||||
- Colored cells used for breathing room—some with minimal/no content
|
||||
|
||||
## Typography
|
||||
|
||||
- Headers: Bold brutalist or retro thick display fonts, high legibility
|
||||
- Body: Clean sans-serif, structured typographic alignment
|
||||
- Decorative English text acceptable for stylistic labels ("WARNING", "INFO", "BEST")
|
||||
- All content text in specified language
|
||||
|
||||
## Style Enforcement
|
||||
|
||||
- Absolutely no gradients, shading, drop shadows, or 3D effects
|
||||
- Everything anchored in grid cells—no floating or unorganized elements
|
||||
- Maintain 1970s retro pop art and underground comic illustration feel
|
||||
- Visual density balanced with rhythmic grid—some cells intentionally sparse for contrast
|
||||
|
||||
## Avoid
|
||||
|
||||
- 3D rendering, realistic details, gradients, soft shadows
|
||||
- Soft, thin, or sketch-like pencil lines
|
||||
- Free-flowing, unorganized, or floating layouts (everything must be grid-anchored)
|
||||
- Pure white background canvas
|
||||
- Organic or hand-drawn imperfections
|
||||
|
||||
## Best For
|
||||
|
||||
Trendy product guides, design-conscious content, visually striking comparisons, content targeting design-savvy audiences, bold social media posts
|
||||
@@ -68,15 +68,32 @@ Use `AskUserQuestion` to ask whether to format first. Formatting can fix:
|
||||
|
||||
**If user declines**: Continue with original file.
|
||||
|
||||
### Step 1: Confirm Theme
|
||||
### Step 1: Determine Theme
|
||||
|
||||
Before converting, use AskUserQuestion to confirm the theme (unless user already specified):
|
||||
**Theme resolution order** (first match wins):
|
||||
1. User explicitly specified theme (CLI `--theme` or conversation)
|
||||
2. EXTEND.md `default_theme` (this skill's own EXTEND.md, checked in Step 0)
|
||||
3. `baoyu-post-to-wechat` EXTEND.md `default_theme` (cross-skill fallback)
|
||||
4. If none found → use AskUserQuestion to confirm
|
||||
|
||||
**Cross-skill EXTEND.md check** (only if this skill's EXTEND.md has no `default_theme`):
|
||||
|
||||
```bash
|
||||
# Check baoyu-post-to-wechat EXTEND.md for default_theme
|
||||
test -f "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md" && grep -o 'default_theme:.*' "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md"
|
||||
```
|
||||
|
||||
**If theme is resolved from EXTEND.md**: Use it directly, do NOT ask the user.
|
||||
|
||||
**If no default found**: Use AskUserQuestion to confirm:
|
||||
|
||||
| Theme | Description |
|
||||
|-------|-------------|
|
||||
| `default` (Recommended) | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
|
||||
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 |
|
||||
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 |
|
||||
| `red` | 红色主题 - 红金配色,宋体排版,传统书法风格 |
|
||||
| `orange` | 橙色主题 - 暖色调现代风,宽松行距,圆角装饰 |
|
||||
|
||||
### Step 2: Convert
|
||||
|
||||
@@ -98,7 +115,7 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts <markdown_file> [options]
|
||||
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| `--theme <name>` | Theme name (default, grace, simple) | default |
|
||||
| `--theme <name>` | Theme name (default, grace, simple, red, orange) | default |
|
||||
| `--title <title>` | Override title from frontmatter | |
|
||||
| `--keep-title` | Keep the first heading in content | false (removed) |
|
||||
| `--help` | Show help | |
|
||||
@@ -154,6 +171,8 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --title "My Article"
|
||||
| `default` | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
|
||||
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 (by @brzhang) |
|
||||
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 (by @okooo5km) |
|
||||
| `red` | 红色主题 - 红金配色,宋体排版,传统书法风格 |
|
||||
| `orange` | 橙色主题 - 暖色调现代风,宽松行距,圆角装饰 |
|
||||
|
||||
## Supported Markdown Features
|
||||
|
||||
|
||||
@@ -126,7 +126,18 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
|
||||
|
||||
const { frontmatter, body } = parseFrontmatter(content);
|
||||
|
||||
let title = options?.title ?? frontmatter.title ?? '';
|
||||
const stripQuotes = (s?: string): string => {
|
||||
if (!s) return '';
|
||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||
return s.slice(1, -1);
|
||||
}
|
||||
if ((s.startsWith('\u201c') && s.endsWith('\u201d')) || (s.startsWith('\u2018') && s.endsWith('\u2019'))) {
|
||||
return s.slice(1, -1);
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
let title = options?.title ?? stripQuotes(frontmatter.title) ?? '';
|
||||
if (!title) {
|
||||
const lines = body.split('\n');
|
||||
for (const line of lines) {
|
||||
@@ -138,8 +149,8 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
|
||||
}
|
||||
}
|
||||
if (!title) title = path.basename(markdownPath, path.extname(markdownPath));
|
||||
const author = frontmatter.author || '';
|
||||
let summary = frontmatter.description || frontmatter.summary || '';
|
||||
const author = stripQuotes(frontmatter.author);
|
||||
let summary = stripQuotes(frontmatter.description) || stripQuotes(frontmatter.summary);
|
||||
|
||||
if (!summary) {
|
||||
const lines = body.split('\n');
|
||||
@@ -214,7 +225,6 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
|
||||
}
|
||||
|
||||
fs.copyFileSync(tempHtmlPath, finalHtmlPath);
|
||||
console.error(`[markdown-to-html] HTML saved to: ${finalHtmlPath}`);
|
||||
|
||||
const contentImages: ImageInfo[] = [];
|
||||
for (const img of images) {
|
||||
@@ -226,6 +236,15 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
|
||||
});
|
||||
}
|
||||
|
||||
let htmlContent = fs.readFileSync(finalHtmlPath, 'utf-8');
|
||||
for (const img of contentImages) {
|
||||
const imgTag = `<img src="${img.placeholder}" data-local-path="${img.localPath}" style="display: block; width: 100%; margin: 1.5em auto;">`;
|
||||
htmlContent = htmlContent.replace(img.placeholder, imgTag);
|
||||
}
|
||||
fs.writeFileSync(finalHtmlPath, htmlContent, 'utf-8');
|
||||
|
||||
console.error(`[markdown-to-html] HTML saved to: ${finalHtmlPath}`);
|
||||
|
||||
return {
|
||||
title,
|
||||
author,
|
||||
|
||||
+1395
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2",
|
||||
"front-matter": "^4.0.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"juice": "^11.0.1",
|
||||
"marked": "^15.0.6",
|
||||
"reading-time": "^1.5.0",
|
||||
"remark-cjk-friendly": "^1.1.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-stringify": "^11.0.0",
|
||||
"unified": "^11.0.5"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import fs from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import frontMatter from "front-matter";
|
||||
import hljs from "highlight.js/lib/core";
|
||||
import { marked, type RendererObject, type Tokens } from "marked";
|
||||
import readingTime, { type ReadTimeResults } from "reading-time";
|
||||
import { unified } from "unified";
|
||||
import remarkParse from "remark-parse";
|
||||
import remarkCjkFriendly from "remark-cjk-friendly";
|
||||
import remarkStringify from "remark-stringify";
|
||||
|
||||
import {
|
||||
markedAlert,
|
||||
@@ -36,10 +41,47 @@ const EXTERNAL_THEME_DIR =
|
||||
|| path.resolve(path.dirname(EXTERNAL_THEME_CONFIG_PATH), "theme-css");
|
||||
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
|
||||
|
||||
const DEFAULT_STYLE = {
|
||||
const FONT_FAMILY_MAP: Record<string, string> = {
|
||||
sans: `-apple-system-font,BlinkMacSystemFont, Helvetica Neue, PingFang SC, Hiragino Sans GB , Microsoft YaHei UI , Microsoft YaHei ,Arial,sans-serif`,
|
||||
serif: `Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, 'PingFang SC', Cambria, Cochin, Georgia, Times, 'Times New Roman', serif`,
|
||||
mono: `Menlo, Monaco, 'Courier New', monospace`,
|
||||
};
|
||||
|
||||
const FONT_SIZE_OPTIONS = ["14px", "15px", "16px", "17px", "18px"];
|
||||
|
||||
const CODE_BLOCK_THEMES = [
|
||||
"1c-light", "a11y-dark", "a11y-light", "agate", "an-old-hope",
|
||||
"androidstudio", "arduino-light", "arta", "ascetic",
|
||||
"atom-one-dark-reasonable", "atom-one-dark", "atom-one-light",
|
||||
"brown-paper", "codepen-embed", "color-brewer", "dark", "default",
|
||||
"devibeans", "docco", "far", "felipec", "foundation",
|
||||
"github-dark-dimmed", "github-dark", "github", "gml", "googlecode",
|
||||
"gradient-dark", "gradient-light", "grayscale", "hybrid", "idea",
|
||||
"intellij-light", "ir-black", "isbl-editor-dark", "isbl-editor-light",
|
||||
"kimbie-dark", "kimbie-light", "lightfair", "lioshi", "magula",
|
||||
"mono-blue", "monokai-sublime", "monokai", "night-owl", "nnfx-dark",
|
||||
"nnfx-light", "nord", "obsidian", "panda-syntax-dark",
|
||||
"panda-syntax-light", "paraiso-dark", "paraiso-light", "pojoaque",
|
||||
"purebasic", "qtcreator-dark", "qtcreator-light", "rainbow", "routeros",
|
||||
"school-book", "shades-of-purple", "srcery", "stackoverflow-dark",
|
||||
"stackoverflow-light", "sunburst", "tokyo-night-dark", "tokyo-night-light",
|
||||
"tomorrow-night-blue", "tomorrow-night-bright", "vs", "vs2015", "xcode",
|
||||
"xt256",
|
||||
];
|
||||
|
||||
const HLJS_CDN_BASE = "https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/npm/highlightjs/11.11.1";
|
||||
|
||||
interface StyleConfig {
|
||||
primaryColor: string;
|
||||
fontFamily: string;
|
||||
fontSize: string;
|
||||
foreground: string;
|
||||
blockquoteBackground: string;
|
||||
}
|
||||
|
||||
const DEFAULT_STYLE: StyleConfig = {
|
||||
primaryColor: "#0F4C81",
|
||||
fontFamily:
|
||||
"-apple-system-font,BlinkMacSystemFont, Helvetica Neue, PingFang SC, Hiragino Sans GB , Microsoft YaHei UI , Microsoft YaHei ,Arial,sans-serif",
|
||||
fontFamily: FONT_FAMILY_MAP.sans!,
|
||||
fontSize: "16px",
|
||||
foreground: "0 0% 3.9%",
|
||||
blockquoteBackground: "#f7f7f7",
|
||||
@@ -486,46 +528,133 @@ function printUsage(): void {
|
||||
console.error(
|
||||
[
|
||||
"Usage:",
|
||||
" npx tsx src/md/render.ts <markdown_file> [--theme <name>]",
|
||||
" npx tsx src/md/render.ts <markdown_file> [options]",
|
||||
"",
|
||||
"Options:",
|
||||
` --theme Theme name (${THEME_NAMES.join(", ")})`,
|
||||
` --theme <name> Theme (${THEME_NAMES.join(", ")})`,
|
||||
` --color <hex> Primary color (default: ${DEFAULT_STYLE.primaryColor})`,
|
||||
` --font-family <name> Font: sans, serif, mono, or CSS value`,
|
||||
` --font-size <N> Font size: ${FONT_SIZE_OPTIONS.join(", ")} (default: 16px)`,
|
||||
` --code-theme <name> Code highlight theme (default: github)`,
|
||||
` --mac-code-block Show Mac-style code block header`,
|
||||
` --line-number Show line numbers in code blocks`,
|
||||
` --cite Enable footnote citations`,
|
||||
` --count Show reading time / word count`,
|
||||
` --legend <value> Image caption: title-alt, alt-title, title, alt, none`,
|
||||
` --keep-title Keep the first heading in output`,
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgValue(argv: string[], i: number, flag: string): string | null {
|
||||
const arg = argv[i]!;
|
||||
if (arg.includes("=")) {
|
||||
return arg.slice(flag.length + 1);
|
||||
}
|
||||
const next = argv[i + 1];
|
||||
return next ?? null;
|
||||
}
|
||||
|
||||
function resolveFontFamily(value: string): string {
|
||||
return FONT_FAMILY_MAP[value] ?? value;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions | null {
|
||||
const ext = loadExtendConfig();
|
||||
|
||||
let inputPath = "";
|
||||
let theme: ThemeName = "default";
|
||||
let keepTitle = false;
|
||||
let theme: ThemeName = ext.default_theme ?? "default";
|
||||
let keepTitle = ext.keep_title ?? false;
|
||||
let primaryColor = ext.default_color ?? DEFAULT_STYLE.primaryColor;
|
||||
let fontFamily = ext.default_font_family ? resolveFontFamily(ext.default_font_family) : DEFAULT_STYLE.fontFamily;
|
||||
let fontSize = ext.default_font_size ?? DEFAULT_STYLE.fontSize;
|
||||
let codeTheme = ext.default_code_theme ?? "github";
|
||||
let isMacCodeBlock = ext.mac_code_block ?? true;
|
||||
let isShowLineNumber = ext.show_line_number ?? false;
|
||||
let citeStatus = ext.cite ?? false;
|
||||
let countStatus = ext.count ?? false;
|
||||
let legend = ext.legend ?? "alt";
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
const arg = argv[i]!;
|
||||
|
||||
if (!arg.startsWith("--") && !inputPath) {
|
||||
inputPath = arg;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--theme") {
|
||||
theme = (argv[i + 1] || "") as ThemeName;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith("--theme=")) {
|
||||
theme = arg.slice("--theme=".length) as ThemeName;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--keep-title") {
|
||||
keepTitle = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (arg === "--keep-title") { keepTitle = true; continue; }
|
||||
if (arg === "--mac-code-block") { isMacCodeBlock = true; continue; }
|
||||
if (arg === "--no-mac-code-block") { isMacCodeBlock = false; continue; }
|
||||
if (arg === "--line-number") { isShowLineNumber = true; continue; }
|
||||
if (arg === "--cite") { citeStatus = true; continue; }
|
||||
if (arg === "--count") { countStatus = true; continue; }
|
||||
|
||||
if (arg === "--theme" || arg.startsWith("--theme=")) {
|
||||
const val = parseArgValue(argv, i, "--theme");
|
||||
if (!val) { console.error("Missing value for --theme"); return null; }
|
||||
theme = val as ThemeName;
|
||||
if (!arg.includes("=")) i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--color" || arg.startsWith("--color=")) {
|
||||
const val = parseArgValue(argv, i, "--color");
|
||||
if (!val) { console.error("Missing value for --color"); return null; }
|
||||
primaryColor = val;
|
||||
if (!arg.includes("=")) i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--font-family" || arg.startsWith("--font-family=")) {
|
||||
const val = parseArgValue(argv, i, "--font-family");
|
||||
if (!val) { console.error("Missing value for --font-family"); return null; }
|
||||
fontFamily = resolveFontFamily(val);
|
||||
if (!arg.includes("=")) i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--font-size" || arg.startsWith("--font-size=")) {
|
||||
const val = parseArgValue(argv, i, "--font-size");
|
||||
if (!val) { console.error("Missing value for --font-size"); return null; }
|
||||
fontSize = val.endsWith("px") ? val : `${val}px`;
|
||||
if (!FONT_SIZE_OPTIONS.includes(fontSize)) {
|
||||
console.error(`Invalid font size: ${fontSize}. Valid: ${FONT_SIZE_OPTIONS.join(", ")}`);
|
||||
return null;
|
||||
}
|
||||
if (!arg.includes("=")) i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--code-theme" || arg.startsWith("--code-theme=")) {
|
||||
const val = parseArgValue(argv, i, "--code-theme");
|
||||
if (!val) { console.error("Missing value for --code-theme"); return null; }
|
||||
codeTheme = val;
|
||||
if (!CODE_BLOCK_THEMES.includes(codeTheme)) {
|
||||
console.error(`Unknown code theme: ${codeTheme}`);
|
||||
return null;
|
||||
}
|
||||
if (!arg.includes("=")) i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--legend" || arg.startsWith("--legend=")) {
|
||||
const val = parseArgValue(argv, i, "--legend");
|
||||
if (!val) { console.error("Missing value for --legend"); return null; }
|
||||
const valid = ["title-alt", "alt-title", "title", "alt", "none"];
|
||||
if (!valid.includes(val)) {
|
||||
console.error(`Invalid legend: ${val}. Valid: ${valid.join(", ")}`);
|
||||
return null;
|
||||
}
|
||||
legend = val;
|
||||
if (!arg.includes("=")) i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.error(`Unknown argument: ${arg}`);
|
||||
return null;
|
||||
}
|
||||
@@ -540,9 +669,8 @@ function parseArgs(argv: string[]): CliOptions | null {
|
||||
}
|
||||
|
||||
return {
|
||||
inputPath,
|
||||
theme,
|
||||
keepTitle,
|
||||
inputPath, theme, keepTitle, primaryColor, fontFamily, fontSize,
|
||||
codeTheme, isMacCodeBlock, isShowLineNumber, citeStatus, countStatus, legend,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -550,14 +678,121 @@ interface CliOptions {
|
||||
inputPath: string;
|
||||
theme: ThemeName;
|
||||
keepTitle: boolean;
|
||||
primaryColor: string;
|
||||
fontFamily: string;
|
||||
fontSize: string;
|
||||
codeTheme: string;
|
||||
isMacCodeBlock: boolean;
|
||||
isShowLineNumber: boolean;
|
||||
citeStatus: boolean;
|
||||
countStatus: boolean;
|
||||
legend: string;
|
||||
}
|
||||
|
||||
interface ExtendConfig {
|
||||
default_theme: string | null;
|
||||
default_color: string | null;
|
||||
default_font_family: string | null;
|
||||
default_font_size: string | null;
|
||||
default_code_theme: string | null;
|
||||
mac_code_block: boolean | null;
|
||||
show_line_number: boolean | null;
|
||||
cite: boolean | null;
|
||||
count: boolean | null;
|
||||
legend: string | null;
|
||||
keep_title: boolean | null;
|
||||
}
|
||||
|
||||
function extractYamlFrontMatter(content: string): string | null {
|
||||
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*$/m);
|
||||
return match ? match[1]! : null;
|
||||
}
|
||||
|
||||
function parseExtendYaml(yaml: string): Partial<ExtendConfig> {
|
||||
const config: Partial<ExtendConfig> = {};
|
||||
for (const line of yaml.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const colonIdx = trimmed.indexOf(":");
|
||||
if (colonIdx < 0) continue;
|
||||
const key = trimmed.slice(0, colonIdx).trim();
|
||||
let value = trimmed.slice(colonIdx + 1).trim().replace(/^['"]|['"]$/g, "");
|
||||
if (value === "null" || value === "") continue;
|
||||
|
||||
if (key === "default_theme") config.default_theme = value;
|
||||
else if (key === "default_color") config.default_color = value;
|
||||
else if (key === "default_font_family") config.default_font_family = value;
|
||||
else if (key === "default_font_size") config.default_font_size = value.endsWith("px") ? value : `${value}px`;
|
||||
else if (key === "default_code_theme") config.default_code_theme = value;
|
||||
else if (key === "mac_code_block") config.mac_code_block = value === "true";
|
||||
else if (key === "show_line_number") config.show_line_number = value === "true";
|
||||
else if (key === "cite") config.cite = value === "true";
|
||||
else if (key === "count") config.count = value === "true";
|
||||
else if (key === "legend") config.legend = value;
|
||||
else if (key === "keep_title") config.keep_title = value === "true";
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function loadExtendConfig(): Partial<ExtendConfig> {
|
||||
const paths = [
|
||||
path.join(process.cwd(), ".baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"),
|
||||
path.join(homedir(), ".baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"),
|
||||
];
|
||||
for (const p of paths) {
|
||||
try {
|
||||
const content = fs.readFileSync(p, "utf-8");
|
||||
const yaml = extractYamlFrontMatter(content);
|
||||
if (!yaml) continue;
|
||||
return parseExtendYaml(yaml);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function preprocessCjkEmphasis(markdown: string): string {
|
||||
const processor = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkCjkFriendly);
|
||||
const tree = processor.parse(markdown);
|
||||
const visit = (node: any, parent?: any, index?: number) => {
|
||||
if (node.children) {
|
||||
for (let i = 0; i < node.children.length; i++) {
|
||||
visit(node.children[i], node, i);
|
||||
}
|
||||
}
|
||||
if (node.type === "strong" && parent && typeof index === "number") {
|
||||
const text = extractText(node);
|
||||
parent.children[index] = { type: "html", value: `<strong>${text}</strong>` };
|
||||
}
|
||||
if (node.type === "emphasis" && parent && typeof index === "number") {
|
||||
const text = extractText(node);
|
||||
parent.children[index] = { type: "html", value: `<em>${text}</em>` };
|
||||
}
|
||||
};
|
||||
const extractText = (node: any): string => {
|
||||
if (node.type === "text") return node.value;
|
||||
if (node.children) return node.children.map(extractText).join("");
|
||||
return "";
|
||||
};
|
||||
visit(tree);
|
||||
const stringify = unified().use(remarkStringify);
|
||||
let result = stringify.stringify(tree);
|
||||
result = result.replace(/&#x([0-9A-Fa-f]+);/g, (_, hex) =>
|
||||
String.fromCodePoint(parseInt(hex, 16))
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
function renderMarkdown(raw: string, renderer: RendererAPI): {
|
||||
html: string;
|
||||
readingTime: ReadTimeResults;
|
||||
} {
|
||||
const preprocessed = preprocessCjkEmphasis(raw);
|
||||
const { markdownContent, readingTime: readingTimeResult } =
|
||||
renderer.parseFrontMatterAndContent(raw);
|
||||
renderer.parseFrontMatterAndContent(preprocessed);
|
||||
|
||||
const html = marked.parse(markdownContent) as string;
|
||||
|
||||
@@ -640,14 +875,14 @@ function loadThemeCss(theme: ThemeName): {
|
||||
};
|
||||
}
|
||||
|
||||
function buildCss(baseCss: string, themeCss: string): string {
|
||||
function buildCss(baseCss: string, themeCss: string, style: StyleConfig = DEFAULT_STYLE): string {
|
||||
const variables = `
|
||||
:root {
|
||||
--md-primary-color: ${DEFAULT_STYLE.primaryColor};
|
||||
--md-font-family: ${DEFAULT_STYLE.fontFamily};
|
||||
--md-font-size: ${DEFAULT_STYLE.fontSize};
|
||||
--foreground: ${DEFAULT_STYLE.foreground};
|
||||
--blockquote-background: ${DEFAULT_STYLE.blockquoteBackground};
|
||||
--md-primary-color: ${style.primaryColor};
|
||||
--md-font-family: ${style.fontFamily};
|
||||
--md-font-size: ${style.fontSize};
|
||||
--foreground: ${style.foreground};
|
||||
--blockquote-background: ${style.blockquoteBackground};
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -669,23 +904,56 @@ function normalizeThemeCss(css: string): string {
|
||||
return stripOutputScope(css);
|
||||
}
|
||||
|
||||
function buildHtmlDocument(title: string, css: string, html: string): string {
|
||||
return [
|
||||
async function fetchCodeThemeCss(themeName: string): Promise<string> {
|
||||
const url = `${HLJS_CDN_BASE}/styles/${themeName}.min.css`;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
console.error(`Failed to fetch code theme CSS: ${res.status} ${url}`);
|
||||
return "";
|
||||
}
|
||||
return await res.text();
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch code theme CSS from ${url}:`, error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
interface HtmlDocumentMeta {
|
||||
title: string;
|
||||
author?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
function buildHtmlDocument(meta: HtmlDocumentMeta, css: string, html: string, codeThemeCss?: string): string {
|
||||
const lines = [
|
||||
"<!doctype html>",
|
||||
"<html>",
|
||||
"<head>",
|
||||
' <meta charset="utf-8" />',
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
||||
` <title>${title}</title>`,
|
||||
` <style>${css}</style>`,
|
||||
` <title>${meta.title}</title>`,
|
||||
];
|
||||
if (meta.author) {
|
||||
lines.push(` <meta name="author" content="${meta.author}" />`);
|
||||
}
|
||||
if (meta.description) {
|
||||
lines.push(` <meta name="description" content="${meta.description}" />`);
|
||||
}
|
||||
lines.push(` <style>${css}</style>`);
|
||||
if (codeThemeCss) {
|
||||
lines.push(` <style>${codeThemeCss}</style>`);
|
||||
}
|
||||
lines.push(
|
||||
"</head>",
|
||||
"<body>",
|
||||
' <div id="output">',
|
||||
html,
|
||||
" </div>",
|
||||
"</body>",
|
||||
"</html>",
|
||||
].join("\n");
|
||||
"</html>"
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function inlineCss(html: string): Promise<string> {
|
||||
@@ -704,12 +972,12 @@ async function inlineCss(html: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCssText(cssText: string): string {
|
||||
function normalizeCssText(cssText: string, style: StyleConfig = DEFAULT_STYLE): string {
|
||||
return cssText
|
||||
.replace(/var\(--md-primary-color\)/g, DEFAULT_STYLE.primaryColor)
|
||||
.replace(/var\(--md-font-family\)/g, DEFAULT_STYLE.fontFamily)
|
||||
.replace(/var\(--md-font-size\)/g, DEFAULT_STYLE.fontSize)
|
||||
.replace(/var\(--blockquote-background\)/g, DEFAULT_STYLE.blockquoteBackground)
|
||||
.replace(/var\(--md-primary-color\)/g, style.primaryColor)
|
||||
.replace(/var\(--md-font-family\)/g, style.fontFamily)
|
||||
.replace(/var\(--md-font-size\)/g, style.fontSize)
|
||||
.replace(/var\(--blockquote-background\)/g, style.blockquoteBackground)
|
||||
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
|
||||
.replace(/--md-primary-color:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-font-family:\s*[^;"']+;?/g, "")
|
||||
@@ -718,20 +986,20 @@ function normalizeCssText(cssText: string): string {
|
||||
.replace(/--foreground:\s*[^;"']+;?/g, "");
|
||||
}
|
||||
|
||||
function normalizeInlineCss(html: string): string {
|
||||
function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string {
|
||||
let output = html;
|
||||
output = output.replace(
|
||||
/<style([^>]*)>([\s\S]*?)<\/style>/gi,
|
||||
(_match, attrs: string, cssText: string) =>
|
||||
`<style${attrs}>${normalizeCssText(cssText)}</style>`
|
||||
`<style${attrs}>${normalizeCssText(cssText, style)}</style>`
|
||||
);
|
||||
output = output.replace(
|
||||
/style="([^"]*)"/gi,
|
||||
(_match, cssText: string) => `style="${normalizeCssText(cssText)}"`
|
||||
(_match, cssText: string) => `style="${normalizeCssText(cssText, style)}"`
|
||||
);
|
||||
output = output.replace(
|
||||
/style='([^']*)'/gi,
|
||||
(_match, cssText: string) => `style='${normalizeCssText(cssText)}'`
|
||||
(_match, cssText: string) => `style='${normalizeCssText(cssText, style)}'`
|
||||
);
|
||||
return output;
|
||||
}
|
||||
@@ -770,11 +1038,28 @@ async function main(): Promise<void> {
|
||||
options.inputPath.replace(/\.md$/i, ".html")
|
||||
);
|
||||
|
||||
const style: StyleConfig = {
|
||||
...DEFAULT_STYLE,
|
||||
primaryColor: options.primaryColor,
|
||||
fontFamily: options.fontFamily,
|
||||
fontSize: options.fontSize,
|
||||
};
|
||||
|
||||
const { baseCss, themeCss } = loadThemeCss(options.theme);
|
||||
const css = normalizeThemeCss(buildCss(baseCss, themeCss));
|
||||
const css = normalizeThemeCss(buildCss(baseCss, themeCss, style));
|
||||
|
||||
const codeThemeCss = await fetchCodeThemeCss(options.codeTheme);
|
||||
|
||||
const markdown = fs.readFileSync(inputPath, "utf-8");
|
||||
|
||||
const renderer = initRenderer({});
|
||||
const renderer = initRenderer({
|
||||
legend: options.legend,
|
||||
citeStatus: options.citeStatus,
|
||||
countStatus: options.countStatus,
|
||||
isMacCodeBlock: options.isMacCodeBlock,
|
||||
isShowLineNumber: options.isShowLineNumber,
|
||||
});
|
||||
const { yamlData } = renderer.parseFrontMatterAndContent(markdown);
|
||||
const { html: baseHtml, readingTime: readingTimeResult } = renderMarkdown(
|
||||
markdown,
|
||||
renderer
|
||||
@@ -784,9 +1069,24 @@ async function main(): Promise<void> {
|
||||
content = removeFirstHeading(content);
|
||||
}
|
||||
|
||||
const title = path.basename(outputPath, ".html");
|
||||
const html = buildHtmlDocument(title, css, content);
|
||||
const inlinedHtml = normalizeInlineCss(await inlineCss(html));
|
||||
const stripQuotes = (s?: string): string | undefined => {
|
||||
if (!s) return s;
|
||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||
return s.slice(1, -1);
|
||||
}
|
||||
if ((s.startsWith('\u201c') && s.endsWith('\u201d')) || (s.startsWith('\u2018') && s.endsWith('\u2019'))) {
|
||||
return s.slice(1, -1);
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
const meta: HtmlDocumentMeta = {
|
||||
title: stripQuotes(yamlData.title) || path.basename(outputPath, ".html"),
|
||||
author: stripQuotes(yamlData.author),
|
||||
description: stripQuotes(yamlData.description) || stripQuotes(yamlData.summary),
|
||||
};
|
||||
const html = buildHtmlDocument(meta, css, content, codeThemeCss);
|
||||
const inlinedHtml = normalizeInlineCss(await inlineCss(html), style);
|
||||
const finalHtml = modifyHtmlStructure(inlinedHtml);
|
||||
|
||||
let backupPath = "";
|
||||
|
||||
@@ -32,3 +32,8 @@ blockquote {
|
||||
#output section > :first-child {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.mermaid-diagram .nodeLabel p {
|
||||
color: unset !important;
|
||||
letter-spacing: unset !important;
|
||||
}
|
||||
|
||||
@@ -269,6 +269,7 @@ pre.code__pre,
|
||||
padding: 0 !important;
|
||||
line-height: 1.5;
|
||||
margin: 10px 8px;
|
||||
box-shadow: inset 0 0 10px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
/* ==================== 图片 ==================== */
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* MD 橙色主题
|
||||
* 暖色调现代风,橙色主色,宽松行距
|
||||
*/
|
||||
|
||||
/* ==================== 容器样式覆盖 ==================== */
|
||||
section,
|
||||
container {
|
||||
font-family: PingFang SC, system-ui, -apple-system, BlinkMacSystemFont, Helvetica Neue, Hiragino Sans GB, Microsoft YaHei UI, Microsoft YaHei, Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 2;
|
||||
background-color: rgba(250, 249, 245, 1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.01);
|
||||
border-radius: 25px;
|
||||
padding: 12px 12px;
|
||||
}
|
||||
|
||||
#output {
|
||||
font-family: PingFang SC, system-ui, -apple-system, BlinkMacSystemFont, Helvetica Neue, Hiragino Sans GB, Microsoft YaHei UI, Microsoft YaHei, Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
/* ==================== 一级标题 ==================== */
|
||||
h1 {
|
||||
display: table;
|
||||
padding: 0.3em 1em;
|
||||
margin: 20px auto;
|
||||
color: #3B3B38;
|
||||
background: #D97757;
|
||||
border-radius: 15px;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ==================== 二级标题 ==================== */
|
||||
h2 {
|
||||
display: block;
|
||||
padding: 0.2em 0;
|
||||
padding-bottom: 0;
|
||||
margin: 0 auto 20px;
|
||||
width: 100%;
|
||||
color: #D97757;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid #E4B1A0;
|
||||
}
|
||||
|
||||
/* ==================== 三级标题 ==================== */
|
||||
h3 {
|
||||
margin: 0 8px 10px;
|
||||
color: #3B3B38;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* ==================== 四级标题 ==================== */
|
||||
h4 {
|
||||
margin: 0 8px 10px;
|
||||
color: #D97757;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* ==================== 五级标题 ==================== */
|
||||
h5 {
|
||||
display: inline-block;
|
||||
margin: 0 8px 10px;
|
||||
padding: 4px 10px;
|
||||
color: #3B3B38;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgb(189, 224, 254);
|
||||
border-radius: 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ==================== 六级标题 ==================== */
|
||||
h6 {
|
||||
margin: 0 8px 10px;
|
||||
color: #D97757;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* ==================== 段落 ==================== */
|
||||
p {
|
||||
margin: 20px 0;
|
||||
color: #3B3B38;
|
||||
line-height: 2;
|
||||
letter-spacing: 0px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ==================== 引用块 ==================== */
|
||||
blockquote {
|
||||
font-style: normal;
|
||||
padding: 15px 12px;
|
||||
border-left: 7px solid rgba(228, 177, 160, 1);
|
||||
border-radius: 10px;
|
||||
color: #3B3B38;
|
||||
background-color: rgba(255, 255, 255, 0.6);
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
blockquote > p {
|
||||
color: #3B3B38;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ==================== 代码块 ==================== */
|
||||
pre.code__pre,
|
||||
.hljs.code__pre {
|
||||
border-radius: 10px;
|
||||
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* ==================== 图片 ==================== */
|
||||
img {
|
||||
border-radius: 10px;
|
||||
margin: 5px auto;
|
||||
}
|
||||
|
||||
/* ==================== 列表 ==================== */
|
||||
ol {
|
||||
padding-left: 1em;
|
||||
margin: 15px 0;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 15px 0;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.2em 0;
|
||||
color: #3B3B38;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ==================== 分隔线 ==================== */
|
||||
hr {
|
||||
border-style: solid;
|
||||
border-width: 1px 0 0;
|
||||
border-color: #D97757;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ==================== 强调 ==================== */
|
||||
strong {
|
||||
color: #3B3B38;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* ==================== 标记高亮 ==================== */
|
||||
.markup-highlight {
|
||||
background-color: #3B3B3B;
|
||||
padding: 10px;
|
||||
color: #FAF9F5;
|
||||
}
|
||||
|
||||
.markup-underline {
|
||||
text-decoration: underline;
|
||||
text-decoration-color: #E4B1A0;
|
||||
}
|
||||
|
||||
/* ==================== 链接 ==================== */
|
||||
a {
|
||||
color: #D97757;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ==================== 表格 ==================== */
|
||||
th {
|
||||
background: rgba(217, 119, 87, 0.1);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* MD 红色主题
|
||||
* 传统书法风格,红金配色,宋体排版
|
||||
*/
|
||||
|
||||
/* ==================== 容器字体覆盖 ==================== */
|
||||
section,
|
||||
container {
|
||||
font-family: "Source Han Serif SC", "Noto Serif CJK SC", "Source Han Serif CN", STSong, SimSun, serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
background-color: rgba(255, 251, 240, 1);
|
||||
border: 4px solid rgba(169, 50, 38, 1);
|
||||
border-radius: 12px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
#output {
|
||||
font-family: "Source Han Serif SC", "Noto Serif CJK SC", "Source Han Serif CN", STSong, SimSun, serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* ==================== 一级标题 ==================== */
|
||||
h1 {
|
||||
display: table;
|
||||
padding: 0 1em;
|
||||
border-bottom: 2px solid #A93226;
|
||||
margin: 30px auto 20px;
|
||||
color: #A93226;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ==================== 二级标题 ==================== */
|
||||
h2 {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
padding: 6px 20px;
|
||||
margin: 30px auto 20px;
|
||||
color: #FFFFFF;
|
||||
background-color: #A93226;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
border-radius: 8px;
|
||||
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 6px;
|
||||
line-height: 1.6em;
|
||||
}
|
||||
|
||||
/* ==================== 三级标题 ==================== */
|
||||
h3 {
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid #A93226;
|
||||
margin: 20px 8px 10px 0;
|
||||
color: #A93226;
|
||||
font-size: 17px;
|
||||
font-weight: normal;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* ==================== 四级标题 ==================== */
|
||||
h4 {
|
||||
margin: 10px 8px;
|
||||
color: #D4AC0D;
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* ==================== 五级标题 ==================== */
|
||||
h5 {
|
||||
margin: 10px 8px;
|
||||
color: #797D7F;
|
||||
font-size: 15px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* ==================== 六级标题 ==================== */
|
||||
h6 {
|
||||
margin: 10px 8px;
|
||||
color: #797D7F;
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* ==================== 段落 ==================== */
|
||||
p {
|
||||
margin: 16px 0;
|
||||
letter-spacing: 0.5px;
|
||||
color: #2C2C2C;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* ==================== 引用块 ==================== */
|
||||
blockquote {
|
||||
font-style: normal;
|
||||
padding: 15px 12px;
|
||||
border-left: 2px solid rgba(169, 50, 38, 0.8);
|
||||
border-radius: 4px;
|
||||
color: #5D4037;
|
||||
background-color: rgba(253, 237, 236, 1);
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
blockquote > p {
|
||||
color: #5D4037;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ==================== 代码块 ==================== */
|
||||
pre.code__pre,
|
||||
.hljs.code__pre {
|
||||
font-size: 13px;
|
||||
border: 1px solid #D4AC0D;
|
||||
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* ==================== 图片 ==================== */
|
||||
img {
|
||||
border-radius: 4px;
|
||||
border: 1px solid #D4AC0D;
|
||||
margin: 20px auto;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
/* ==================== 列表 ==================== */
|
||||
ol {
|
||||
padding-left: 1em;
|
||||
margin: 15px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 15px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.2em 0;
|
||||
color: #2C2C2C;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* ==================== 分隔线 ==================== */
|
||||
hr {
|
||||
border-style: solid;
|
||||
border-width: 2px 0 0;
|
||||
border-color: #D4AC0D;
|
||||
margin: 40px 0;
|
||||
}
|
||||
|
||||
/* ==================== 强调 ==================== */
|
||||
strong {
|
||||
color: #A93226;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* ==================== 标记高亮 ==================== */
|
||||
.markup-highlight {
|
||||
background-color: #FADBD8;
|
||||
padding: 5px;
|
||||
color: #A93226;
|
||||
}
|
||||
|
||||
.markup-underline {
|
||||
text-decoration: underline;
|
||||
text-decoration-color: #D4AC0D;
|
||||
}
|
||||
|
||||
/* ==================== 链接 ==================== */
|
||||
a {
|
||||
color: #A93226;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ==================== 表格 ==================== */
|
||||
th {
|
||||
background: rgba(169, 50, 38, 0.1);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: baoyu-post-to-wechat
|
||||
description: Posts content to WeChat Official Account (微信公众号) via API or Chrome CDP. Supports article posting (文章) with HTML, markdown, or plain text input, and image-text posting (图文) with multiple images. Use when user mentions "发布公众号", "post to wechat", "微信公众号", or "图文/文章".
|
||||
description: Posts content to WeChat Official Account (微信公众号) via API or Chrome CDP. Supports article posting (文章) with HTML, markdown, or plain text input, and image-text posting (贴图, formerly 图文) with multiple images. Use when user mentions "发布公众号", "post to wechat", "微信公众号", or "贴图/图文/文章".
|
||||
---
|
||||
|
||||
# Post to WeChat Official Account
|
||||
@@ -18,6 +18,7 @@ description: Posts content to WeChat Official Account (微信公众号) via API
|
||||
| `scripts/wechat-browser.ts` | Image-text posts (图文) |
|
||||
| `scripts/wechat-article.ts` | Article posting via browser (文章) |
|
||||
| `scripts/wechat-api.ts` | Article posting via API (文章) |
|
||||
| `scripts/check-permissions.ts` | Verify environment & permissions |
|
||||
|
||||
## Preferences (EXTEND.md)
|
||||
|
||||
@@ -44,10 +45,60 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md" && echo "user"
|
||||
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||
│ Found │ Read, parse, apply settings │
|
||||
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||
│ Not found │ Use defaults │
|
||||
│ Not found │ Run first-time setup ([references/config/first-time-setup.md](references/config/first-time-setup.md)) → Save → Continue │
|
||||
└───────────┴───────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
**EXTEND.md Supports**: Default theme | Default publishing method (api/browser) | Default author | Chrome profile path
|
||||
**EXTEND.md Supports**: Default theme | Default publishing method (api/browser) | Default author | Default open-comment switch | Default fans-only-comment switch | Chrome profile path
|
||||
|
||||
First-time setup: [references/config/first-time-setup.md](references/config/first-time-setup.md)
|
||||
|
||||
**Minimum supported keys** (case-insensitive, accept `1/0` or `true/false`):
|
||||
|
||||
| Key | Default | Mapping |
|
||||
|-----|---------|---------|
|
||||
| `default_author` | empty | Fallback for `author` when CLI/frontmatter not provided |
|
||||
| `need_open_comment` | `1` | `articles[].need_open_comment` in `draft/add` request |
|
||||
| `only_fans_can_comment` | `0` | `articles[].only_fans_can_comment` in `draft/add` request |
|
||||
|
||||
**Recommended EXTEND.md example**:
|
||||
|
||||
```md
|
||||
default_theme: default
|
||||
default_publish_method: api
|
||||
default_author: 宝玉
|
||||
need_open_comment: 1
|
||||
only_fans_can_comment: 0
|
||||
chrome_profile_path: /path/to/chrome/profile
|
||||
```
|
||||
|
||||
**Value priority**:
|
||||
1. CLI arguments
|
||||
2. Frontmatter
|
||||
3. EXTEND.md
|
||||
4. Skill defaults
|
||||
|
||||
## Pre-flight Check (Optional)
|
||||
|
||||
Before first use, suggest running the environment check. User can skip if they prefer.
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/check-permissions.ts
|
||||
```
|
||||
|
||||
Checks: Chrome, profile isolation, Bun, Accessibility, clipboard, paste keystroke, API credentials, Chrome conflicts.
|
||||
|
||||
**If any check fails**, provide fix guidance per item:
|
||||
|
||||
| Check | Fix |
|
||||
|-------|-----|
|
||||
| Chrome | Install Chrome or set `WECHAT_BROWSER_CHROME_PATH` env var |
|
||||
| Profile dir | Ensure `~/.local/share/wechat-browser-profile` is writable |
|
||||
| Bun runtime | `curl -fsSL https://bun.sh/install \| bash` |
|
||||
| Accessibility (macOS) | System Settings → Privacy & Security → Accessibility → enable terminal app |
|
||||
| Clipboard copy | Ensure Swift/AppKit available (macOS Xcode CLI tools: `xcode-select --install`) |
|
||||
| Paste keystroke (macOS) | Same as Accessibility fix above |
|
||||
| Paste keystroke (Linux) | Install `xdotool` (X11) or `ydotool` (Wayland) |
|
||||
| API credentials | Follow guided setup in Step 5, or manually set in `.baoyu-skills/.env` |
|
||||
|
||||
## Image-Text Posting (图文)
|
||||
|
||||
@@ -70,7 +121,7 @@ Publishing Progress:
|
||||
- [ ] Step 1: Determine input type
|
||||
- [ ] Step 2: Check markdown-to-html skill
|
||||
- [ ] Step 3: Convert to HTML
|
||||
- [ ] Step 4: Validate metadata (title, summary)
|
||||
- [ ] Step 4: Validate metadata (title, summary, cover)
|
||||
- [ ] Step 5: Select method and configure credentials
|
||||
- [ ] Step 6: Publish to WeChat
|
||||
- [ ] Step 7: Report completion
|
||||
@@ -80,6 +131,13 @@ Publishing Progress:
|
||||
|
||||
Check and load EXTEND.md settings (see Preferences section above).
|
||||
|
||||
**CRITICAL**: If not found, complete first-time setup BEFORE any other steps or questions.
|
||||
|
||||
Resolve and store these defaults for later steps:
|
||||
- `default_author`
|
||||
- `need_open_comment` (default `1`)
|
||||
- `only_fans_can_comment` (default `0`)
|
||||
|
||||
### Step 1: Determine Input Type
|
||||
|
||||
| Input Type | Detection | Action |
|
||||
@@ -138,20 +196,19 @@ B) Continue - provide HTML file manually
|
||||
|
||||
**Skip if**: Input is `.html` file
|
||||
|
||||
1. **Ask theme preference** (unless specified in EXTEND.md or CLI):
|
||||
1. **Resolve theme** (first match wins, do NOT ask user if resolved):
|
||||
- CLI `--theme` argument
|
||||
- EXTEND.md `default_theme` (loaded in Step 0)
|
||||
- Fallback: `default`
|
||||
|
||||
| Theme | Description |
|
||||
|-------|-------------|
|
||||
| `default` | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
|
||||
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 |
|
||||
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 |
|
||||
|
||||
2. **Execute conversion** (using the discovered skill):
|
||||
2. **Execute conversion** (using the discovered skill), **always pass `--theme`**:
|
||||
|
||||
```bash
|
||||
npx -y bun ${MD_TO_HTML_SKILL_DIR}/scripts/main.ts <markdown_file> --theme <theme>
|
||||
```
|
||||
|
||||
**CRITICAL**: Always include `--theme` parameter. Never omit it, even if using `default`.
|
||||
|
||||
3. **Parse JSON output** to get: `htmlPath`, `title`, `author`, `summary`, `contentImages`
|
||||
|
||||
### Step 4: Validate Metadata
|
||||
@@ -162,11 +219,19 @@ Check extracted metadata from Step 3 (or HTML meta tags if direct HTML input).
|
||||
|-------|------------|
|
||||
| Title | Prompt: "Enter title, or press Enter to auto-generate from content" |
|
||||
| Summary | Prompt: "Enter summary, or press Enter to auto-generate (recommended for SEO)" |
|
||||
| Author | Use fallback chain: CLI `--author` → frontmatter `author` → EXTEND.md `default_author` |
|
||||
|
||||
**Auto-Generation Logic**:
|
||||
- **Title**: First H1/H2 heading, or first sentence
|
||||
- **Summary**: First paragraph, truncated to 120 characters
|
||||
|
||||
**Cover Image Check** (required for `article_type=news`):
|
||||
1. Use CLI `--cover` if provided.
|
||||
2. Else use frontmatter (`coverImage`, `featureImage`, `cover`, `image`).
|
||||
3. Else check article directory default path: `imgs/cover.png`.
|
||||
4. Else fallback to first inline content image.
|
||||
5. If still missing, stop and request a cover image before publishing.
|
||||
|
||||
### Step 5: Select Publishing Method and Configure
|
||||
|
||||
**Ask publishing method** (unless specified in EXTEND.md or CLI):
|
||||
@@ -213,9 +278,20 @@ WECHAT_APP_SECRET=<user_input>
|
||||
**API method**:
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/wechat-api.ts <html_file> [--title <title>] [--summary <summary>]
|
||||
npx -y bun ${SKILL_DIR}/scripts/wechat-api.ts <html_file> [--title <title>] [--summary <summary>] [--author <author>] [--cover <cover_path>]
|
||||
```
|
||||
|
||||
**`draft/add` payload rules**:
|
||||
- Use endpoint: `POST https://api.weixin.qq.com/cgi-bin/draft/add?access_token=ACCESS_TOKEN`
|
||||
- `article_type`: `news` (default) or `newspic`
|
||||
- For `news`, include `thumb_media_id` (cover is required)
|
||||
- Always resolve and send:
|
||||
- `need_open_comment` (default `1`)
|
||||
- `only_fans_can_comment` (default `0`)
|
||||
- `author` resolution: CLI `--author` → frontmatter `author` → EXTEND.md `default_author`
|
||||
|
||||
If script parameters do not expose the two comment fields, still ensure final API request body includes resolved values.
|
||||
|
||||
**Browser method**:
|
||||
|
||||
```bash
|
||||
@@ -237,6 +313,7 @@ Article:
|
||||
• Title: [title]
|
||||
• Summary: [summary]
|
||||
• Images: [N] inline images
|
||||
• Comments: [open/closed], [fans-only/all users]
|
||||
|
||||
Result:
|
||||
✓ Draft saved to WeChat Official Account
|
||||
@@ -289,6 +366,8 @@ Files created:
|
||||
| Multiple images | ✓ (up to 9) | ✓ (inline) | ✓ (inline) |
|
||||
| Themes | ✗ | ✓ | ✓ |
|
||||
| Auto-generate metadata | ✗ | ✓ | ✓ |
|
||||
| Default cover fallback (`imgs/cover.png`) | ✗ | ✓ | ✗ |
|
||||
| Comment control (`need_open_comment`, `only_fans_can_comment`) | ✗ | ✓ | ✗ |
|
||||
| Requires Chrome | ✓ | ✗ | ✓ |
|
||||
| Requires API credentials | ✗ | ✓ | ✗ |
|
||||
| Speed | Medium | Fast | Slow |
|
||||
@@ -322,6 +401,8 @@ Files created:
|
||||
| Not logged in (browser) | First run opens browser - scan QR to log in |
|
||||
| Chrome not found | Set `WECHAT_BROWSER_CHROME_PATH` env var |
|
||||
| Title/summary missing | Use auto-generation or provide manually |
|
||||
| No cover image | Add frontmatter cover or place `imgs/cover.png` in article directory |
|
||||
| Wrong comment defaults | Check `EXTEND.md` keys `need_open_comment` and `only_fans_can_comment` |
|
||||
| Paste fails | Check system clipboard permissions |
|
||||
|
||||
## Extension Support
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
name: first-time-setup
|
||||
description: First-time setup flow for baoyu-post-to-wechat preferences
|
||||
---
|
||||
|
||||
# First-Time Setup
|
||||
|
||||
## Overview
|
||||
|
||||
When no EXTEND.md is found, guide user through preference setup.
|
||||
|
||||
**BLOCKING OPERATION**: This setup MUST complete before ANY other workflow steps. Do NOT:
|
||||
- Ask about content or files to publish
|
||||
- Ask about themes or publishing methods
|
||||
- Proceed to content conversion or publishing
|
||||
|
||||
ONLY ask the questions in this setup flow, save EXTEND.md, then continue.
|
||||
|
||||
## Setup Flow
|
||||
|
||||
```
|
||||
No EXTEND.md found
|
||||
|
|
||||
v
|
||||
+---------------------+
|
||||
| AskUserQuestion |
|
||||
| (all questions) |
|
||||
+---------------------+
|
||||
|
|
||||
v
|
||||
+---------------------+
|
||||
| Create EXTEND.md |
|
||||
+---------------------+
|
||||
|
|
||||
v
|
||||
Continue to Step 1
|
||||
```
|
||||
|
||||
## Questions
|
||||
|
||||
**Language**: Use user's input language or saved language preference.
|
||||
|
||||
Use AskUserQuestion with ALL questions in ONE call:
|
||||
|
||||
### Question 1: Default Theme
|
||||
|
||||
```yaml
|
||||
header: "Theme"
|
||||
question: "Default theme for article conversion?"
|
||||
options:
|
||||
- label: "default (Recommended)"
|
||||
description: "Classic layout - centered title with border, white-on-color H2"
|
||||
- label: "grace"
|
||||
description: "Elegant - text shadows, rounded cards, refined blockquotes"
|
||||
- label: "simple"
|
||||
description: "Minimal modern - asymmetric rounded corners, clean whitespace"
|
||||
```
|
||||
|
||||
### Question 2: Default Publishing Method
|
||||
|
||||
```yaml
|
||||
header: "Method"
|
||||
question: "Default publishing method?"
|
||||
options:
|
||||
- label: "api (Recommended)"
|
||||
description: "Fast, requires API credentials (AppID + AppSecret)"
|
||||
- label: "browser"
|
||||
description: "Slow, requires Chrome and login session"
|
||||
```
|
||||
|
||||
### Question 3: Default Author
|
||||
|
||||
```yaml
|
||||
header: "Author"
|
||||
question: "Default author name for articles?"
|
||||
options:
|
||||
- label: "No default"
|
||||
description: "Leave empty, specify per article"
|
||||
```
|
||||
|
||||
Note: User will likely choose "Other" to type their author name.
|
||||
|
||||
### Question 4: Open Comments
|
||||
|
||||
```yaml
|
||||
header: "Comments"
|
||||
question: "Enable comments on articles by default?"
|
||||
options:
|
||||
- label: "Yes (Recommended)"
|
||||
description: "Allow readers to comment on articles"
|
||||
- label: "No"
|
||||
description: "Disable comments by default"
|
||||
```
|
||||
|
||||
### Question 5: Fans-Only Comments
|
||||
|
||||
```yaml
|
||||
header: "Fans only"
|
||||
question: "Restrict comments to followers only?"
|
||||
options:
|
||||
- label: "No (Recommended)"
|
||||
description: "All readers can comment"
|
||||
- label: "Yes"
|
||||
description: "Only followers can comment"
|
||||
```
|
||||
|
||||
### Question 6: Save Location
|
||||
|
||||
```yaml
|
||||
header: "Save"
|
||||
question: "Where to save preferences?"
|
||||
options:
|
||||
- label: "Project (Recommended)"
|
||||
description: ".baoyu-skills/ (this project only)"
|
||||
- label: "User"
|
||||
description: "~/.baoyu-skills/ (all projects)"
|
||||
```
|
||||
|
||||
## Save Locations
|
||||
|
||||
| Choice | Path | Scope |
|
||||
|--------|------|-------|
|
||||
| Project | `.baoyu-skills/baoyu-post-to-wechat/EXTEND.md` | Current project |
|
||||
| User | `~/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md` | All projects |
|
||||
|
||||
## After Setup
|
||||
|
||||
1. Create directory if needed
|
||||
2. Write EXTEND.md
|
||||
3. Confirm: "Preferences saved to [path]"
|
||||
4. Continue to Step 0 (load the saved preferences)
|
||||
|
||||
## EXTEND.md Template
|
||||
|
||||
```md
|
||||
default_theme: [default/grace/simple]
|
||||
default_publish_method: [api/browser]
|
||||
default_author: [author name or empty]
|
||||
need_open_comment: [1/0]
|
||||
only_fans_can_comment: [1/0]
|
||||
chrome_profile_path:
|
||||
```
|
||||
|
||||
## Modifying Preferences Later
|
||||
|
||||
Users can edit EXTEND.md directly or delete it to trigger setup again.
|
||||
@@ -1,7 +1,9 @@
|
||||
# Image-Text Posting (图文发表)
|
||||
# Image-Text Posting (贴图发表, formerly 图文)
|
||||
|
||||
Post image-text messages with multiple images to WeChat Official Account.
|
||||
|
||||
> **Note**: WeChat has renamed "图文" to "贴图" in the Official Account menu (as of 2026).
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { findChromeExecutable, getDefaultProfileDir } from './cdp.ts';
|
||||
|
||||
interface CheckResult {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
const results: CheckResult[] = [];
|
||||
|
||||
function log(label: string, ok: boolean, detail: string): void {
|
||||
results.push({ name: label, ok, detail });
|
||||
const icon = ok ? '✅' : '❌';
|
||||
console.log(`${icon} ${label}: ${detail}`);
|
||||
}
|
||||
|
||||
function warn(label: string, detail: string): void {
|
||||
results.push({ name: label, ok: true, detail });
|
||||
console.log(`⚠️ ${label}: ${detail}`);
|
||||
}
|
||||
|
||||
async function checkChrome(): Promise<void> {
|
||||
const chromePath = findChromeExecutable();
|
||||
if (chromePath) {
|
||||
log('Chrome', true, chromePath);
|
||||
} else {
|
||||
log('Chrome', false, 'Not found. Set WECHAT_BROWSER_CHROME_PATH env var or install Chrome.');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkProfileIsolation(): Promise<void> {
|
||||
const profileDir = getDefaultProfileDir();
|
||||
const userChromeDir = process.platform === 'darwin'
|
||||
? path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome')
|
||||
: process.platform === 'win32'
|
||||
? path.join(os.homedir(), 'AppData', 'Local', 'Google', 'Chrome', 'User Data')
|
||||
: path.join(os.homedir(), '.config', 'google-chrome');
|
||||
|
||||
const isIsolated = !profileDir.startsWith(userChromeDir);
|
||||
log('Profile isolation', isIsolated, `Skill profile: ${profileDir}`);
|
||||
|
||||
if (isIsolated) {
|
||||
const exists = fs.existsSync(profileDir);
|
||||
if (exists) {
|
||||
log('Profile dir', true, 'Exists and accessible');
|
||||
} else {
|
||||
try {
|
||||
fs.mkdirSync(profileDir, { recursive: true });
|
||||
log('Profile dir', true, 'Created successfully');
|
||||
} catch (e) {
|
||||
log('Profile dir', false, `Cannot create: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAccessibility(): Promise<void> {
|
||||
if (process.platform !== 'darwin') {
|
||||
log('Accessibility', true, `Skipped (not macOS, platform: ${process.platform})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = spawnSync('osascript', ['-e', `
|
||||
tell application "System Events"
|
||||
set frontApp to name of first application process whose frontmost is true
|
||||
return frontApp
|
||||
end tell
|
||||
`], { stdio: 'pipe', timeout: 10_000 });
|
||||
|
||||
if (result.status === 0) {
|
||||
const app = result.stdout?.toString().trim();
|
||||
log('Accessibility (System Events)', true, `Frontmost app: ${app}`);
|
||||
} else {
|
||||
const stderr = result.stderr?.toString().trim() || '';
|
||||
if (stderr.includes('not allowed assistive access') || stderr.includes('1002')) {
|
||||
log('Accessibility (System Events)', false,
|
||||
'Denied. Grant access: System Settings → Privacy & Security → Accessibility → enable your terminal app');
|
||||
} else {
|
||||
log('Accessibility (System Events)', false, `Failed: ${stderr}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkClipboardCopy(): Promise<void> {
|
||||
if (process.platform !== 'darwin') {
|
||||
log('Clipboard copy (image)', true, `Skipped (not macOS)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'wechat-check-'));
|
||||
try {
|
||||
const testPng = path.join(tmpDir, 'test.png');
|
||||
const swiftSrc = `import AppKit
|
||||
import Foundation
|
||||
let size = NSSize(width: 2, height: 2)
|
||||
let image = NSImage(size: size)
|
||||
image.lockFocus()
|
||||
NSColor.red.set()
|
||||
NSBezierPath.fill(NSRect(origin: .zero, size: size))
|
||||
image.unlockFocus()
|
||||
guard let tiff = image.tiffRepresentation,
|
||||
let rep = NSBitmapImageRep(data: tiff),
|
||||
let png = rep.representation(using: .png, properties: [:]) else {
|
||||
FileHandle.standardError.write("Failed to create test PNG\\n".data(using: .utf8)!)
|
||||
exit(1)
|
||||
}
|
||||
try png.write(to: URL(fileURLWithPath: CommandLine.arguments[1]))
|
||||
`;
|
||||
const genScript = path.join(tmpDir, 'gen.swift');
|
||||
await writeFile(genScript, swiftSrc, 'utf8');
|
||||
const genResult = spawnSync('swift', [genScript, testPng], { stdio: 'pipe', timeout: 30_000 });
|
||||
if (genResult.status !== 0) {
|
||||
log('Clipboard copy (image)', false, `Cannot create test image: ${genResult.stderr?.toString().trim()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const clipSrc = `import AppKit
|
||||
import Foundation
|
||||
guard let image = NSImage(contentsOfFile: CommandLine.arguments[1]) else {
|
||||
FileHandle.standardError.write("Failed to load image\\n".data(using: .utf8)!)
|
||||
exit(1)
|
||||
}
|
||||
let pb = NSPasteboard.general
|
||||
pb.clearContents()
|
||||
if !pb.writeObjects([image]) {
|
||||
FileHandle.standardError.write("Failed to write to clipboard\\n".data(using: .utf8)!)
|
||||
exit(1)
|
||||
}
|
||||
`;
|
||||
const clipScript = path.join(tmpDir, 'clip.swift');
|
||||
await writeFile(clipScript, clipSrc, 'utf8');
|
||||
const clipResult = spawnSync('swift', [clipScript, testPng], { stdio: 'pipe', timeout: 30_000 });
|
||||
if (clipResult.status === 0) {
|
||||
log('Clipboard copy (image)', true, 'Can copy image to clipboard via Swift/AppKit');
|
||||
} else {
|
||||
log('Clipboard copy (image)', false, `Failed: ${clipResult.stderr?.toString().trim()}`);
|
||||
}
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function checkPasteKeystroke(): Promise<void> {
|
||||
if (process.platform === 'darwin') {
|
||||
const result = spawnSync('osascript', ['-e', `
|
||||
tell application "System Events"
|
||||
set canSend to true
|
||||
return canSend
|
||||
end tell
|
||||
`], { stdio: 'pipe', timeout: 10_000 });
|
||||
|
||||
if (result.status === 0) {
|
||||
log('Paste keystroke (osascript)', true, 'System Events can send keystrokes');
|
||||
} else {
|
||||
const stderr = result.stderr?.toString().trim() || '';
|
||||
log('Paste keystroke (osascript)', false, `Cannot send keystrokes: ${stderr}`);
|
||||
}
|
||||
} else if (process.platform === 'linux') {
|
||||
const xdotool = spawnSync('which', ['xdotool'], { stdio: 'pipe' });
|
||||
const ydotool = spawnSync('which', ['ydotool'], { stdio: 'pipe' });
|
||||
if (xdotool.status === 0) {
|
||||
log('Paste keystroke', true, 'xdotool available (X11)');
|
||||
} else if (ydotool.status === 0) {
|
||||
log('Paste keystroke', true, 'ydotool available (Wayland)');
|
||||
} else {
|
||||
log('Paste keystroke', false, 'No tool found. Install xdotool (X11) or ydotool (Wayland).');
|
||||
}
|
||||
} else if (process.platform === 'win32') {
|
||||
log('Paste keystroke', true, 'Windows uses PowerShell SendKeys (built-in)');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkBun(): Promise<void> {
|
||||
const result = spawnSync('npx', ['-y', 'bun', '--version'], { stdio: 'pipe', timeout: 30_000 });
|
||||
if (result.status === 0) {
|
||||
log('Bun runtime', true, `v${result.stdout?.toString().trim()}`);
|
||||
} else {
|
||||
log('Bun runtime', false, 'Cannot run bun. Install: curl -fsSL https://bun.sh/install | bash');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkApiCredentials(): Promise<void> {
|
||||
const cwd = process.cwd();
|
||||
const projectEnv = path.join(cwd, '.baoyu-skills', '.env');
|
||||
const userEnv = path.join(os.homedir(), '.baoyu-skills', '.env');
|
||||
|
||||
let found = false;
|
||||
for (const envPath of [projectEnv, userEnv]) {
|
||||
if (fs.existsSync(envPath)) {
|
||||
const content = fs.readFileSync(envPath, 'utf8');
|
||||
if (content.includes('WECHAT_APP_ID')) {
|
||||
log('API credentials', true, `Found in ${envPath}`);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
warn('API credentials', 'Not found. Required for API publishing method. Run the skill to set up via guided flow.');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkRunningChromeConflict(): Promise<void> {
|
||||
if (process.platform !== 'darwin') return;
|
||||
|
||||
const result = spawnSync('pgrep', ['-f', 'Google Chrome'], { stdio: 'pipe' });
|
||||
const pids = result.stdout?.toString().trim().split('\n').filter(Boolean) || [];
|
||||
|
||||
if (pids.length > 0) {
|
||||
warn('Running Chrome instances', `${pids.length} Chrome process(es) detected. The skill uses --user-data-dir for isolation, so this is safe.`);
|
||||
} else {
|
||||
log('Running Chrome instances', true, 'No existing Chrome processes');
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log('=== baoyu-post-to-wechat: Permission & Environment Check ===\n');
|
||||
|
||||
await checkChrome();
|
||||
await checkProfileIsolation();
|
||||
await checkBun();
|
||||
await checkAccessibility();
|
||||
await checkClipboardCopy();
|
||||
await checkPasteKeystroke();
|
||||
await checkApiCredentials();
|
||||
await checkRunningChromeConflict();
|
||||
|
||||
console.log('\n--- Summary ---');
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
if (failed.length === 0) {
|
||||
console.log('All checks passed. Ready to post to WeChat.');
|
||||
} else {
|
||||
console.log(`${failed.length} issue(s) found:`);
|
||||
for (const f of failed) {
|
||||
console.log(` ❌ ${f.name}: ${f.detail}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
await main().catch((err) => {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -228,25 +228,25 @@ async function copyHtmlLinux(htmlFilePath: string): Promise<void> {
|
||||
}
|
||||
|
||||
async function copyImageWindows(imagePath: string): Promise<void> {
|
||||
const escaped = imagePath.replace(/'/g, "''");
|
||||
const ps = [
|
||||
'param([string]$Path)',
|
||||
'Add-Type -AssemblyName System.Windows.Forms',
|
||||
'Add-Type -AssemblyName System.Drawing',
|
||||
'$img = [System.Drawing.Image]::FromFile($Path)',
|
||||
`$img = [System.Drawing.Image]::FromFile('${escaped}')`,
|
||||
'[System.Windows.Forms.Clipboard]::SetImage($img)',
|
||||
'$img.Dispose()',
|
||||
].join('; ');
|
||||
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps, '-Path', imagePath]);
|
||||
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps]);
|
||||
}
|
||||
|
||||
async function copyHtmlWindows(htmlFilePath: string): Promise<void> {
|
||||
const escaped = htmlFilePath.replace(/'/g, "''");
|
||||
const ps = [
|
||||
'param([string]$Path)',
|
||||
'Add-Type -AssemblyName System.Windows.Forms',
|
||||
'$html = Get-Content -Raw -LiteralPath $Path',
|
||||
`$html = Get-Content -Raw -LiteralPath '${escaped}'`,
|
||||
'[System.Windows.Forms.Clipboard]::SetText($html, [System.Windows.Forms.TextDataFormat]::Html)',
|
||||
].join('; ');
|
||||
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps, '-Path', htmlFilePath]);
|
||||
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps]);
|
||||
}
|
||||
|
||||
async function copyImageToClipboard(imagePathInput: string): Promise<void> {
|
||||
|
||||
@@ -120,22 +120,28 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
|
||||
const { frontmatter, body } = parseFrontmatter(content);
|
||||
|
||||
let title = options?.title ?? frontmatter.title ?? '';
|
||||
let bodyWithoutTitle = body;
|
||||
if (!title) {
|
||||
const lines = body.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const headingMatch = trimmed.match(/^#{1,2}\s+(.+)$/);
|
||||
if (headingMatch) title = headingMatch[1]!;
|
||||
if (headingMatch) {
|
||||
title = headingMatch[1]!;
|
||||
bodyWithoutTitle = body.replace(/^#{1,2}\s+.+\r?\n?/, '');
|
||||
}
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
bodyWithoutTitle = body.replace(/^#{1,2}\s+.+\r?\n?/, '');
|
||||
}
|
||||
if (!title) title = path.basename(markdownPath, path.extname(markdownPath));
|
||||
const author = frontmatter.author || '';
|
||||
let summary = frontmatter.description || frontmatter.summary || '';
|
||||
|
||||
if (!summary) {
|
||||
const lines = body.split('\n');
|
||||
const lines = bodyWithoutTitle.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
@@ -161,7 +167,7 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
|
||||
const images: Array<{ src: string; placeholder: string }> = [];
|
||||
let imageCounter = 0;
|
||||
|
||||
const modifiedBody = body.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
|
||||
const modifiedBody = bodyWithoutTitle.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
|
||||
const placeholder = `WECHATIMGPH_${++imageCounter}`;
|
||||
images.push({ src, placeholder });
|
||||
return placeholder;
|
||||
|
||||
@@ -374,7 +374,7 @@ export function initRenderer(opts: IOpts = {}): RendererAPI {
|
||||
|
||||
listCounters[listCounters.length - 1] = idx + 1;
|
||||
|
||||
const prefix = ordered ? `${idx}. ` : "• ";
|
||||
const prefix = ordered ? "" : "• ";
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
|
||||
@@ -28,6 +28,18 @@ interface PublishResponse {
|
||||
errmsg?: string;
|
||||
}
|
||||
|
||||
type ArticleType = "news" | "newspic";
|
||||
|
||||
interface ArticleOptions {
|
||||
title: string;
|
||||
author?: string;
|
||||
digest?: string;
|
||||
content: string;
|
||||
thumbMediaId: string;
|
||||
articleType: ArticleType;
|
||||
imageMediaIds?: string[];
|
||||
}
|
||||
|
||||
const TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token";
|
||||
const UPLOAD_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material";
|
||||
const DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/add";
|
||||
@@ -176,19 +188,20 @@ async function uploadImagesInHtml(
|
||||
html: string,
|
||||
accessToken: string,
|
||||
baseDir: string
|
||||
): Promise<{ html: string; firstMediaId: string }> {
|
||||
const imgRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
|
||||
): Promise<{ html: string; firstMediaId: string; allMediaIds: string[] }> {
|
||||
const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi;
|
||||
const matches = [...html.matchAll(imgRegex)];
|
||||
|
||||
if (matches.length === 0) {
|
||||
return { html, firstMediaId: "" };
|
||||
return { html, firstMediaId: "", allMediaIds: [] };
|
||||
}
|
||||
|
||||
let firstMediaId = "";
|
||||
let updatedHtml = html;
|
||||
const allMediaIds: string[] = [];
|
||||
|
||||
for (const match of matches) {
|
||||
const [, src] = match;
|
||||
const [fullTag, src] = match;
|
||||
if (!src) continue;
|
||||
|
||||
if (src.startsWith("https://mmbiz.qpic.cn")) {
|
||||
@@ -198,40 +211,70 @@ async function uploadImagesInHtml(
|
||||
continue;
|
||||
}
|
||||
|
||||
console.error(`[wechat-api] Uploading image: ${src}`);
|
||||
const localPathMatch = fullTag.match(/data-local-path=["']([^"']+)["']/);
|
||||
const imagePath = localPathMatch ? localPathMatch[1]! : src;
|
||||
|
||||
console.error(`[wechat-api] Uploading image: ${imagePath}`);
|
||||
try {
|
||||
const resp = await uploadImage(src, accessToken, baseDir);
|
||||
updatedHtml = updatedHtml.replace(src, resp.url);
|
||||
const resp = await uploadImage(imagePath, accessToken, baseDir);
|
||||
const newTag = fullTag
|
||||
.replace(/\ssrc=["'][^"']+["']/, ` src="${resp.url}"`)
|
||||
.replace(/\sdata-local-path=["'][^"']+["']/, "");
|
||||
updatedHtml = updatedHtml.replace(fullTag, newTag);
|
||||
allMediaIds.push(resp.media_id);
|
||||
if (!firstMediaId) {
|
||||
firstMediaId = resp.media_id;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[wechat-api] Failed to upload ${src}:`, err);
|
||||
console.error(`[wechat-api] Failed to upload ${imagePath}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return { html: updatedHtml, firstMediaId };
|
||||
return { html: updatedHtml, firstMediaId, allMediaIds };
|
||||
}
|
||||
|
||||
async function publishToDraft(
|
||||
title: string,
|
||||
html: string,
|
||||
thumbMediaId: string,
|
||||
options: ArticleOptions,
|
||||
accessToken: string
|
||||
): Promise<PublishResponse> {
|
||||
const url = `${DRAFT_URL}?access_token=${accessToken}`;
|
||||
|
||||
let article: Record<string, unknown>;
|
||||
|
||||
if (options.articleType === "newspic") {
|
||||
if (!options.imageMediaIds || options.imageMediaIds.length === 0) {
|
||||
throw new Error("newspic requires at least one image");
|
||||
}
|
||||
article = {
|
||||
article_type: "newspic",
|
||||
title: options.title,
|
||||
content: options.content,
|
||||
need_open_comment: 1,
|
||||
only_fans_can_comment: 0,
|
||||
image_info: {
|
||||
image_list: options.imageMediaIds.map(id => ({ image_media_id: id })),
|
||||
},
|
||||
};
|
||||
if (options.author) article.author = options.author;
|
||||
} else {
|
||||
article = {
|
||||
article_type: "news",
|
||||
title: options.title,
|
||||
content: options.content,
|
||||
thumb_media_id: options.thumbMediaId,
|
||||
need_open_comment: 1,
|
||||
only_fans_can_comment: 0,
|
||||
};
|
||||
if (options.author) article.author = options.author;
|
||||
if (options.digest) article.digest = options.digest;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
articles: [{
|
||||
title,
|
||||
content: html,
|
||||
thumb_media_id: thumbMediaId,
|
||||
}],
|
||||
}),
|
||||
body: JSON.stringify({ articles: [article] }),
|
||||
});
|
||||
|
||||
const data = await res.json() as PublishResponse;
|
||||
@@ -309,12 +352,24 @@ Arguments:
|
||||
file Markdown (.md) or HTML (.html) file
|
||||
|
||||
Options:
|
||||
--type <type> Article type: news (文章, default) or newspic (图文)
|
||||
--title <title> Override title
|
||||
--author <name> Author name (max 16 chars)
|
||||
--summary <text> Article summary/digest (max 128 chars)
|
||||
--theme <name> Theme name for markdown (default, grace, simple). Default: default
|
||||
--cover <path> Cover image path (local or URL)
|
||||
--dry-run Parse and render only, don't publish
|
||||
--help Show this help
|
||||
|
||||
Frontmatter Fields (markdown):
|
||||
title Article title
|
||||
author Author name
|
||||
digest/summary Article summary
|
||||
coverImage/featureImage/cover/image Cover image path
|
||||
|
||||
Comments:
|
||||
Comments are enabled by default, open to all users.
|
||||
|
||||
Environment Variables:
|
||||
WECHAT_APP_ID WeChat App ID
|
||||
WECHAT_APP_SECRET WeChat App Secret
|
||||
@@ -327,7 +382,9 @@ Config File Locations (in priority order):
|
||||
Example:
|
||||
npx -y bun wechat-api.ts article.md
|
||||
npx -y bun wechat-api.ts article.md --theme grace --cover cover.png
|
||||
npx -y bun wechat-api.ts article.md --author "Author Name" --summary "Brief intro"
|
||||
npx -y bun wechat-api.ts article.html --title "My Article"
|
||||
npx -y bun wechat-api.ts images/ --type newspic --title "Photo Album"
|
||||
npx -y bun wechat-api.ts article.md --dry-run
|
||||
`);
|
||||
process.exit(0);
|
||||
@@ -336,7 +393,10 @@ Example:
|
||||
interface CliArgs {
|
||||
filePath: string;
|
||||
isHtml: boolean;
|
||||
articleType: ArticleType;
|
||||
title?: string;
|
||||
author?: string;
|
||||
summary?: string;
|
||||
theme: string;
|
||||
cover?: string;
|
||||
dryRun: boolean;
|
||||
@@ -350,20 +410,32 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = {
|
||||
filePath: "",
|
||||
isHtml: false,
|
||||
articleType: "news",
|
||||
theme: "default",
|
||||
dryRun: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i]!;
|
||||
if (arg === "--title" && argv[i + 1]) {
|
||||
if (arg === "--type" && argv[i + 1]) {
|
||||
const t = argv[++i]!.toLowerCase();
|
||||
if (t === "news" || t === "newspic") {
|
||||
args.articleType = t;
|
||||
}
|
||||
} else if (arg === "--title" && argv[i + 1]) {
|
||||
args.title = argv[++i];
|
||||
} else if (arg === "--author" && argv[i + 1]) {
|
||||
args.author = argv[++i];
|
||||
} else if (arg === "--summary" && argv[i + 1]) {
|
||||
args.summary = argv[++i];
|
||||
} else if (arg === "--theme" && argv[i + 1]) {
|
||||
args.theme = argv[++i]!;
|
||||
} else if (arg === "--cover" && argv[i + 1]) {
|
||||
args.cover = argv[++i];
|
||||
} else if (arg === "--dry-run") {
|
||||
args.dryRun = true;
|
||||
} else if (arg.startsWith("--") && argv[i + 1] && !argv[i + 1]!.startsWith("-")) {
|
||||
i++;
|
||||
} else if (!arg.startsWith("-")) {
|
||||
args.filePath = arg;
|
||||
}
|
||||
@@ -398,6 +470,8 @@ async function main(): Promise<void> {
|
||||
|
||||
const baseDir = path.dirname(filePath);
|
||||
let title = args.title || "";
|
||||
let author = args.author || "";
|
||||
let digest = args.summary || "";
|
||||
let htmlPath: string;
|
||||
let htmlContent: string;
|
||||
let frontmatter: Record<string, string> = {};
|
||||
@@ -405,6 +479,15 @@ async function main(): Promise<void> {
|
||||
if (args.isHtml) {
|
||||
htmlPath = filePath;
|
||||
htmlContent = extractHtmlContent(htmlPath);
|
||||
const mdPath = filePath.replace(/\.html$/i, ".md");
|
||||
if (fs.existsSync(mdPath)) {
|
||||
const mdContent = fs.readFileSync(mdPath, "utf-8");
|
||||
const parsed = parseFrontmatter(mdContent);
|
||||
frontmatter = parsed.frontmatter;
|
||||
if (!title && frontmatter.title) title = frontmatter.title;
|
||||
if (!author) author = frontmatter.author || "";
|
||||
if (!digest) digest = frontmatter.digest || frontmatter.summary || frontmatter.description || "";
|
||||
}
|
||||
if (!title) {
|
||||
title = extractHtmlTitle(fs.readFileSync(htmlPath, "utf-8"));
|
||||
}
|
||||
@@ -420,6 +503,8 @@ async function main(): Promise<void> {
|
||||
const h1Match = body.match(/^#\s+(.+)$/m);
|
||||
if (h1Match) title = h1Match[1]!;
|
||||
}
|
||||
if (!author) author = frontmatter.author || "";
|
||||
if (!digest) digest = frontmatter.digest || frontmatter.summary || frontmatter.description || "";
|
||||
|
||||
console.error(`[wechat-api] Theme: ${args.theme}`);
|
||||
htmlPath = renderMarkdownToHtml(filePath, args.theme);
|
||||
@@ -432,11 +517,24 @@ async function main(): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (digest && digest.length > 120) {
|
||||
const truncated = digest.slice(0, 117);
|
||||
const lastPunct = Math.max(truncated.lastIndexOf("。"), truncated.lastIndexOf(","), truncated.lastIndexOf(";"), truncated.lastIndexOf("、"));
|
||||
digest = lastPunct > 80 ? truncated.slice(0, lastPunct + 1) : truncated + "...";
|
||||
console.error(`[wechat-api] Digest truncated to ${digest.length} chars`);
|
||||
}
|
||||
|
||||
console.error(`[wechat-api] Title: ${title}`);
|
||||
if (author) console.error(`[wechat-api] Author: ${author}`);
|
||||
if (digest) console.error(`[wechat-api] Digest: ${digest.slice(0, 50)}...`);
|
||||
console.error(`[wechat-api] Type: ${args.articleType}`);
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log(JSON.stringify({
|
||||
articleType: args.articleType,
|
||||
title,
|
||||
author: author || undefined,
|
||||
digest: digest || undefined,
|
||||
htmlPath,
|
||||
contentLength: htmlContent.length,
|
||||
}, null, 2));
|
||||
@@ -448,7 +546,7 @@ async function main(): Promise<void> {
|
||||
const accessToken = await fetchAccessToken(config.appId, config.appSecret);
|
||||
|
||||
console.error("[wechat-api] Uploading images...");
|
||||
const { html: processedHtml, firstMediaId } = await uploadImagesInHtml(
|
||||
const { html: processedHtml, firstMediaId, allMediaIds } = await uploadImagesInHtml(
|
||||
htmlContent,
|
||||
accessToken,
|
||||
baseDir
|
||||
@@ -456,11 +554,14 @@ async function main(): Promise<void> {
|
||||
htmlContent = processedHtml;
|
||||
|
||||
let thumbMediaId = "";
|
||||
const coverPath = args.cover ||
|
||||
frontmatter.featureImage ||
|
||||
const rawCoverPath = args.cover ||
|
||||
frontmatter.coverImage ||
|
||||
frontmatter.featureImage ||
|
||||
frontmatter.cover ||
|
||||
frontmatter.image;
|
||||
const coverPath = rawCoverPath && !path.isAbsolute(rawCoverPath) && args.cover
|
||||
? path.resolve(process.cwd(), rawCoverPath)
|
||||
: rawCoverPath;
|
||||
|
||||
if (coverPath) {
|
||||
console.error(`[wechat-api] Uploading cover: ${coverPath}`);
|
||||
@@ -476,18 +577,32 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (!thumbMediaId) {
|
||||
console.error("Error: No cover image. Provide via --cover, frontmatter.featureImage, or include an image in content.");
|
||||
if (args.articleType === "news" && !thumbMediaId) {
|
||||
console.error("Error: No cover image. Provide via --cover, frontmatter.coverImage, or include an image in content.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (args.articleType === "newspic" && allMediaIds.length === 0) {
|
||||
console.error("Error: newspic requires at least one image in content.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.error("[wechat-api] Publishing to draft...");
|
||||
const result = await publishToDraft(title, htmlContent, thumbMediaId, accessToken);
|
||||
const result = await publishToDraft({
|
||||
title,
|
||||
author: author || undefined,
|
||||
digest: digest || undefined,
|
||||
content: htmlContent,
|
||||
thumbMediaId,
|
||||
articleType: args.articleType,
|
||||
imageMediaIds: args.articleType === "newspic" ? allMediaIds : undefined,
|
||||
}, accessToken);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
media_id: result.media_id,
|
||||
title,
|
||||
articleType: args.articleType,
|
||||
}, null, 2));
|
||||
|
||||
console.error(`[wechat-api] Published successfully! media_id: ${result.media_id}`);
|
||||
|
||||
@@ -115,7 +115,7 @@ async function sendPaste(cdp?: CdpConnection, sessionId?: string): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
async function copyHtmlFromBrowser(cdp: CdpConnection, htmlFilePath: string): Promise<void> {
|
||||
async function copyHtmlFromBrowser(cdp: CdpConnection, htmlFilePath: string, contentImages: ImageInfo[] = []): Promise<void> {
|
||||
const absolutePath = path.isAbsolute(htmlFilePath) ? htmlFilePath : path.resolve(process.cwd(), htmlFilePath);
|
||||
const fileUrl = `file://${absolutePath}`;
|
||||
|
||||
@@ -128,6 +128,28 @@ async function copyHtmlFromBrowser(cdp: CdpConnection, htmlFilePath: string): Pr
|
||||
await cdp.send('Runtime.enable', {}, { sessionId });
|
||||
await sleep(2000);
|
||||
|
||||
if (contentImages.length > 0) {
|
||||
console.log('[wechat] Replacing img tags with placeholders for browser paste...');
|
||||
const replacements = contentImages.map(img => ({ placeholder: img.placeholder, localPath: img.localPath }));
|
||||
await cdp.send<{ result: { value: unknown } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
(function() {
|
||||
const replacements = ${JSON.stringify(replacements)};
|
||||
for (const r of replacements) {
|
||||
const imgs = document.querySelectorAll('img[src="' + r.placeholder + '"], img[data-local-path="' + r.localPath + '"]');
|
||||
for (const img of imgs) {
|
||||
const text = document.createTextNode(r.placeholder);
|
||||
img.parentNode.replaceChild(text, img);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})()
|
||||
`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
console.log('[wechat] Selecting #output content...');
|
||||
await cdp.send<{ result: { value: unknown } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
@@ -176,7 +198,7 @@ async function parseMarkdownWithPlaceholders(markdownPath: string, theme?: strin
|
||||
return JSON.parse(output);
|
||||
}
|
||||
|
||||
function parseHtmlMeta(htmlPath: string): { title: string; author: string; summary: string } {
|
||||
function parseHtmlMeta(htmlPath: string): { title: string; author: string; summary: string; contentImages: ImageInfo[] } {
|
||||
const content = fs.readFileSync(htmlPath, 'utf-8');
|
||||
|
||||
let title = '';
|
||||
@@ -203,7 +225,45 @@ function parseHtmlMeta(htmlPath: string): { title: string; author: string; summa
|
||||
}
|
||||
}
|
||||
|
||||
return { title, author, summary };
|
||||
const mdPath = htmlPath.replace(/\.html$/i, '.md');
|
||||
if (fs.existsSync(mdPath)) {
|
||||
const mdContent = fs.readFileSync(mdPath, 'utf-8');
|
||||
const fmMatch = mdContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
if (fmMatch) {
|
||||
const lines = fmMatch[1]!.split('\n');
|
||||
for (const line of lines) {
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
const key = line.slice(0, colonIdx).trim();
|
||||
let value = line.slice(colonIdx + 1).trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (key === 'title' && !title) title = value;
|
||||
if (key === 'author' && !author) author = value;
|
||||
if ((key === 'description' || key === 'summary') && !summary) summary = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const contentImages: ImageInfo[] = [];
|
||||
const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi;
|
||||
const matches = [...content.matchAll(imgRegex)];
|
||||
for (const match of matches) {
|
||||
const [fullTag, src] = match;
|
||||
if (!src || src.startsWith('http')) continue;
|
||||
const localPathMatch = fullTag.match(/data-local-path=["']([^"']+)["']/);
|
||||
if (localPathMatch) {
|
||||
contentImages.push({
|
||||
placeholder: src,
|
||||
localPath: localPathMatch[1]!,
|
||||
originalPath: src,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { title, author, summary, contentImages };
|
||||
}
|
||||
|
||||
async function selectAndReplacePlaceholder(session: ChromeSession, placeholder: string): Promise<boolean> {
|
||||
@@ -213,22 +273,31 @@ async function selectAndReplacePlaceholder(session: ChromeSession, placeholder:
|
||||
const editor = document.querySelector('.ProseMirror');
|
||||
if (!editor) return false;
|
||||
|
||||
const placeholder = ${JSON.stringify(placeholder)};
|
||||
const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false);
|
||||
let node;
|
||||
|
||||
while ((node = walker.nextNode())) {
|
||||
const text = node.textContent || '';
|
||||
const idx = text.indexOf(${JSON.stringify(placeholder)});
|
||||
if (idx !== -1) {
|
||||
node.parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
let searchStart = 0;
|
||||
let idx;
|
||||
// Search for exact match (not prefix of longer placeholder like XIMGPH_1 in XIMGPH_10)
|
||||
while ((idx = text.indexOf(placeholder, searchStart)) !== -1) {
|
||||
const afterIdx = idx + placeholder.length;
|
||||
const charAfter = text[afterIdx];
|
||||
// Exact match if next char is not a digit
|
||||
if (charAfter === undefined || !/\\d/.test(charAfter)) {
|
||||
node.parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(node, idx);
|
||||
range.setEnd(node, idx + ${placeholder.length});
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
return true;
|
||||
const range = document.createRange();
|
||||
range.setStart(node, idx);
|
||||
range.setEnd(node, idx + placeholder.length);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
return true;
|
||||
}
|
||||
searchStart = afterIdx;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -246,6 +315,71 @@ async function pressDeleteKey(session: ChromeSession): Promise<void> {
|
||||
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId: session.sessionId });
|
||||
}
|
||||
|
||||
async function removeExtraEmptyLineAfterImage(session: ChromeSession): Promise<boolean> {
|
||||
const removed = await evaluate<boolean>(session, `
|
||||
(function() {
|
||||
const editor = document.querySelector('.ProseMirror');
|
||||
if (!editor) return false;
|
||||
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return false;
|
||||
|
||||
let node = sel.anchorNode;
|
||||
if (!node) return false;
|
||||
let element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
||||
if (!element || !editor.contains(element)) return false;
|
||||
|
||||
const isEmptyParagraph = (el) => {
|
||||
if (!el || el.tagName !== 'P') return false;
|
||||
const text = (el.textContent || '').trim();
|
||||
if (text.length > 0) return false;
|
||||
return el.querySelectorAll('img, figure, video, iframe').length === 0;
|
||||
};
|
||||
|
||||
const hasImage = (el) => {
|
||||
if (!el) return false;
|
||||
return !!el.querySelector('img, figure img, picture img');
|
||||
};
|
||||
|
||||
const placeCursorAfter = (el) => {
|
||||
if (!el) return;
|
||||
const range = document.createRange();
|
||||
range.setStartAfter(el);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
};
|
||||
|
||||
// Case 1: caret is inside an empty paragraph right after an image block.
|
||||
const emptyPara = element.closest('p');
|
||||
if (emptyPara && editor.contains(emptyPara) && isEmptyParagraph(emptyPara)) {
|
||||
const prev = emptyPara.previousElementSibling;
|
||||
if (prev && hasImage(prev)) {
|
||||
emptyPara.remove();
|
||||
placeCursorAfter(prev);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: caret is on the image block itself; remove the next empty paragraph.
|
||||
const imageBlock = element.closest('figure, p');
|
||||
if (imageBlock && editor.contains(imageBlock) && hasImage(imageBlock)) {
|
||||
const next = imageBlock.nextElementSibling;
|
||||
if (next && isEmptyParagraph(next)) {
|
||||
next.remove();
|
||||
placeCursorAfter(imageBlock);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
})()
|
||||
`);
|
||||
|
||||
if (removed) console.log('[wechat] Removed extra empty line after image.');
|
||||
return removed;
|
||||
}
|
||||
|
||||
export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
const { title, content, htmlFile, markdownFile, theme, author, summary, images = [], submit = false, profileDir, cdpPort } = options;
|
||||
let { contentImages = [] } = options;
|
||||
@@ -273,9 +407,13 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
effectiveAuthor = effectiveAuthor || meta.author;
|
||||
effectiveSummary = effectiveSummary || meta.summary;
|
||||
effectiveHtmlFile = htmlFile;
|
||||
if (meta.contentImages.length > 0) {
|
||||
contentImages = meta.contentImages;
|
||||
}
|
||||
console.log(`[wechat] Title: ${effectiveTitle || '(empty)'}`);
|
||||
console.log(`[wechat] Author: ${effectiveAuthor || '(empty)'}`);
|
||||
console.log(`[wechat] Summary: ${effectiveSummary || '(empty)'}`);
|
||||
console.log(`[wechat] Found ${contentImages.length} images to insert`);
|
||||
}
|
||||
|
||||
if (effectiveTitle && effectiveTitle.length > 64) throw new Error(`Title too long: ${effectiveTitle.length} chars (max 64)`);
|
||||
@@ -381,11 +519,6 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
await evaluate(session, `document.querySelector('#author').value = ${JSON.stringify(effectiveAuthor)}; document.querySelector('#author').dispatchEvent(new Event('input', { bubbles: true }));`);
|
||||
}
|
||||
|
||||
if (effectiveSummary) {
|
||||
console.log(`[wechat] Filling summary: ${effectiveSummary}`);
|
||||
await evaluate(session, `document.querySelector('#js_description').value = ${JSON.stringify(effectiveSummary)}; document.querySelector('#js_description').dispatchEvent(new Event('input', { bubbles: true }));`);
|
||||
}
|
||||
|
||||
await sleep(500);
|
||||
|
||||
if (effectiveTitle) {
|
||||
@@ -397,15 +530,6 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveSummary) {
|
||||
const actualSummary = await evaluate<string>(session, `document.querySelector('#js_description')?.value || ''`);
|
||||
if (actualSummary === effectiveSummary) {
|
||||
console.log('[wechat] Summary verified OK.');
|
||||
} else {
|
||||
console.warn(`[wechat] Summary verification failed. Expected: "${effectiveSummary}", got: "${actualSummary}"`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[wechat] Clicking on editor...');
|
||||
await clickElement(session, '.ProseMirror');
|
||||
await sleep(1000);
|
||||
@@ -416,7 +540,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
|
||||
if (effectiveHtmlFile && fs.existsSync(effectiveHtmlFile)) {
|
||||
console.log(`[wechat] Copying HTML content from: ${effectiveHtmlFile}`);
|
||||
await copyHtmlFromBrowser(cdp, effectiveHtmlFile);
|
||||
await copyHtmlFromBrowser(cdp, effectiveHtmlFile, contentImages);
|
||||
await sleep(500);
|
||||
console.log('[wechat] Pasting into editor...');
|
||||
await pasteFromClipboardInEditor(session);
|
||||
@@ -461,6 +585,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
console.log('[wechat] Pasting image...');
|
||||
await pasteFromClipboardInEditor(session);
|
||||
await sleep(3000);
|
||||
await removeExtraEmptyLineAfterImage(session);
|
||||
}
|
||||
console.log('[wechat] All images inserted.');
|
||||
}
|
||||
@@ -472,6 +597,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
await sleep(500);
|
||||
await pasteInEditor(session);
|
||||
await sleep(2000);
|
||||
await removeExtraEmptyLineAfterImage(session);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,6 +620,30 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveSummary) {
|
||||
console.log(`[wechat] Filling summary (after content paste): ${effectiveSummary}`);
|
||||
await evaluate(session, `
|
||||
(function() {
|
||||
const el = document.querySelector('#js_description');
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.select();
|
||||
el.value = ${JSON.stringify(effectiveSummary)};
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('blur', { bubbles: true }));
|
||||
})()
|
||||
`);
|
||||
await sleep(500);
|
||||
|
||||
const actualSummary = await evaluate<string>(session, `document.querySelector('#js_description')?.value || ''`);
|
||||
if (actualSummary === effectiveSummary) {
|
||||
console.log('[wechat] Summary verified OK.');
|
||||
} else {
|
||||
console.warn(`[wechat] Summary verification failed. Expected: "${effectiveSummary}", got: "${actualSummary}"`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[wechat] Saving as draft...');
|
||||
await evaluate(session, `document.querySelector('#js_submit button').click()`);
|
||||
await sleep(3000);
|
||||
|
||||
@@ -397,7 +397,7 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
|
||||
|
||||
await sleep(2000);
|
||||
|
||||
console.log('[wechat-browser] Looking for "图文" menu...');
|
||||
console.log('[wechat-browser] Looking for "贴图" menu...');
|
||||
const menuResult = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
const menuItems = document.querySelectorAll('.new-creation__menu .new-creation__menu-item');
|
||||
@@ -417,7 +417,7 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
|
||||
const initialIds = new Set(initialTargets.targetInfos.map(t => t.targetId));
|
||||
console.log(`[wechat-browser] Initial targets count: ${initialTargets.targetInfos.length}`);
|
||||
|
||||
console.log('[wechat-browser] Finding "图文" menu position...');
|
||||
console.log('[wechat-browser] Finding "贴图" menu position...');
|
||||
const menuPos = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
(function() {
|
||||
@@ -427,10 +427,10 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
|
||||
const title = item.querySelector('.new-creation__menu-title');
|
||||
const text = title?.textContent?.trim() || '';
|
||||
console.log('Menu item text:', text);
|
||||
if (text === '图文') {
|
||||
if (text === '图文' || text === '贴图') {
|
||||
item.scrollIntoView({ block: 'center' });
|
||||
const rect = item.getBoundingClientRect();
|
||||
console.log('Found 图文,rect:', JSON.stringify(rect));
|
||||
console.log('Found 贴图,rect:', JSON.stringify(rect));
|
||||
return JSON.stringify({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2, width: rect.width, height: rect.height });
|
||||
}
|
||||
}
|
||||
@@ -442,9 +442,9 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
|
||||
console.log(`[wechat-browser] Menu position: ${menuPos.result.value}`);
|
||||
|
||||
const pos = menuPos.result.value !== 'null' ? JSON.parse(menuPos.result.value) : null;
|
||||
if (!pos) throw new Error('图文 menu not found or not visible');
|
||||
if (!pos) throw new Error('贴图 menu not found or not visible');
|
||||
|
||||
console.log('[wechat-browser] Clicking "图文" menu with mouse events...');
|
||||
console.log('[wechat-browser] Clicking "贴图" menu with mouse events...');
|
||||
await cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed',
|
||||
x: pos.x,
|
||||
@@ -533,11 +533,22 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
|
||||
console.log(`[wechat-browser] Images: ${absolutePaths.join(', ')}`);
|
||||
|
||||
const { root } = await cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {}, { sessionId });
|
||||
const { nodeId } = await cdp.send<{ nodeId: number }>('DOM.querySelector', {
|
||||
|
||||
// Try primary selector, then fallback to any multi-file image input
|
||||
let { nodeId } = await cdp.send<{ nodeId: number }>('DOM.querySelector', {
|
||||
nodeId: root.nodeId,
|
||||
selector: '.js_upload_btn_container input[type=file]',
|
||||
}, { sessionId });
|
||||
|
||||
if (!nodeId) {
|
||||
console.log('[wechat-browser] Primary file input not found, trying fallback selector...');
|
||||
const fallback = await cdp.send<{ nodeId: number }>('DOM.querySelector', {
|
||||
nodeId: root.nodeId,
|
||||
selector: 'input[type=file][multiple][accept*="image"]',
|
||||
}, { sessionId });
|
||||
nodeId = fallback.nodeId;
|
||||
}
|
||||
|
||||
if (!nodeId) throw new Error('File input not found');
|
||||
|
||||
await cdp.send('DOM.setFileInputFiles', {
|
||||
@@ -545,7 +556,27 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
|
||||
files: absolutePaths,
|
||||
}, { sessionId });
|
||||
|
||||
await sleep(1000);
|
||||
// Dispatch change event to trigger the upload
|
||||
await cdp.send('Runtime.evaluate', {
|
||||
expression: `
|
||||
const fileInput = document.querySelector('.js_upload_btn_container input[type=file]') || document.querySelector('input[type=file][multiple][accept*="image"]');
|
||||
if (fileInput) fileInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
`,
|
||||
}, { sessionId });
|
||||
|
||||
// Wait for images to upload
|
||||
console.log('[wechat-browser] Waiting for images to upload...');
|
||||
const targetCount = absolutePaths.length;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await sleep(2000);
|
||||
const uploadCheck = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `JSON.stringify({ uploaded: document.querySelectorAll('.weui-desktop-upload__thumb, .pic_item, [class*=upload_thumb]').length })`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
const status = JSON.parse(uploadCheck.result.value);
|
||||
console.log(`[wechat-browser] Upload progress: ${status.uploaded}/${targetCount}`);
|
||||
if (status.uploaded >= targetCount) break;
|
||||
}
|
||||
|
||||
console.log('[wechat-browser] Filling title...');
|
||||
await cdp.send('Runtime.evaluate', {
|
||||
@@ -561,73 +592,134 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
|
||||
}, { sessionId });
|
||||
await sleep(500);
|
||||
|
||||
console.log('[wechat-browser] Clicking on content editor...');
|
||||
const editorPos = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
console.log('[wechat-browser] Filling content...');
|
||||
// Try ProseMirror editor first (new WeChat UI), then fallback to old editor
|
||||
const contentResult = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
(function() {
|
||||
const editor = document.querySelector('.js_pmEditorArea');
|
||||
if (editor) {
|
||||
const rect = editor.getBoundingClientRect();
|
||||
return JSON.stringify({ x: rect.x + 50, y: rect.y + 20 });
|
||||
const contentHtml = ${JSON.stringify('<p>' + content.split('\n').filter(l => l.trim()).join('</p><p>') + '</p>')};
|
||||
|
||||
// New UI: ProseMirror contenteditable
|
||||
const pm = document.querySelector('.ProseMirror[contenteditable=true]');
|
||||
if (pm) {
|
||||
pm.innerHTML = contentHtml;
|
||||
pm.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
return 'ProseMirror: content set, length=' + pm.textContent.length;
|
||||
}
|
||||
return 'null';
|
||||
|
||||
// Old UI: .js_pmEditorArea
|
||||
const oldEditor = document.querySelector('.js_pmEditorArea');
|
||||
if (oldEditor) {
|
||||
return JSON.stringify({ type: 'old', x: oldEditor.getBoundingClientRect().x + 50, y: oldEditor.getBoundingClientRect().y + 20 });
|
||||
}
|
||||
|
||||
return 'editor_not_found';
|
||||
})()
|
||||
`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
|
||||
if (editorPos.result.value === 'null') throw new Error('Content editor not found');
|
||||
const editorClickPos = JSON.parse(editorPos.result.value);
|
||||
const contentStatus = contentResult.result.value;
|
||||
console.log(`[wechat-browser] Content result: ${contentStatus}`);
|
||||
|
||||
await cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed',
|
||||
x: editorClickPos.x,
|
||||
y: editorClickPos.y,
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
}, { sessionId });
|
||||
await sleep(50);
|
||||
await cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mouseReleased',
|
||||
x: editorClickPos.x,
|
||||
y: editorClickPos.y,
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
}, { sessionId });
|
||||
await sleep(300);
|
||||
|
||||
console.log('[wechat-browser] Typing content with keyboard simulation...');
|
||||
const lines = content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.length > 0) {
|
||||
await cdp.send('Input.insertText', { text: line }, { sessionId });
|
||||
}
|
||||
if (i < lines.length - 1) {
|
||||
await cdp.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyDown',
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
windowsVirtualKeyCode: 13,
|
||||
}, { sessionId });
|
||||
await cdp.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyUp',
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
windowsVirtualKeyCode: 13,
|
||||
}, { sessionId });
|
||||
}
|
||||
await sleep(50);
|
||||
if (contentStatus === 'editor_not_found') {
|
||||
throw new Error('Content editor not found');
|
||||
}
|
||||
|
||||
// Fallback: old editor uses keyboard simulation
|
||||
if (contentStatus.startsWith('{')) {
|
||||
const editorClickPos = JSON.parse(contentStatus);
|
||||
if (editorClickPos.type === 'old') {
|
||||
console.log('[wechat-browser] Using old editor with keyboard simulation...');
|
||||
await cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed',
|
||||
x: editorClickPos.x,
|
||||
y: editorClickPos.y,
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
}, { sessionId });
|
||||
await sleep(50);
|
||||
await cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mouseReleased',
|
||||
x: editorClickPos.x,
|
||||
y: editorClickPos.y,
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
}, { sessionId });
|
||||
await sleep(300);
|
||||
|
||||
const lines = content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line!.length > 0) {
|
||||
await cdp.send('Input.insertText', { text: line }, { sessionId });
|
||||
}
|
||||
if (i < lines.length - 1) {
|
||||
await cdp.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyDown',
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
windowsVirtualKeyCode: 13,
|
||||
}, { sessionId });
|
||||
await cdp.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyUp',
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
windowsVirtualKeyCode: 13,
|
||||
}, { sessionId });
|
||||
}
|
||||
await sleep(50);
|
||||
}
|
||||
console.log('[wechat-browser] Content typed via keyboard.');
|
||||
}
|
||||
}
|
||||
console.log('[wechat-browser] Content typed.');
|
||||
await sleep(500);
|
||||
|
||||
if (submit) {
|
||||
console.log('[wechat-browser] Saving as draft...');
|
||||
await cdp.send('Runtime.evaluate', {
|
||||
expression: `document.querySelector('#js_submit')?.click()`,
|
||||
const submitResult = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
(function() {
|
||||
// Try new UI: find button by text
|
||||
const allBtns = document.querySelectorAll('button');
|
||||
for (const btn of allBtns) {
|
||||
const text = btn.textContent?.trim();
|
||||
if (text === '保存为草稿') {
|
||||
btn.click();
|
||||
return 'clicked:保存为草稿';
|
||||
}
|
||||
}
|
||||
// Fallback: old UI selector
|
||||
const oldBtn = document.querySelector('#js_submit');
|
||||
if (oldBtn) {
|
||||
oldBtn.click();
|
||||
return 'clicked:#js_submit';
|
||||
}
|
||||
// List available buttons for debugging
|
||||
const btnTexts = [];
|
||||
allBtns.forEach(b => {
|
||||
const t = b.textContent?.trim();
|
||||
if (t && t.length < 20) btnTexts.push(t);
|
||||
});
|
||||
return 'not_found:' + btnTexts.join(',');
|
||||
})()
|
||||
`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
console.log(`[wechat-browser] Submit result: ${submitResult.result.value}`);
|
||||
await sleep(3000);
|
||||
|
||||
// Verify save success by checking for toast
|
||||
const toastCheck = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
const toasts = document.querySelectorAll('.weui-desktop-toast, [class*=toast]');
|
||||
const msgs = [];
|
||||
toasts.forEach(t => { const text = t.textContent?.trim(); if (text) msgs.push(text); });
|
||||
JSON.stringify(msgs);
|
||||
`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
console.log(`[wechat-browser] Toast messages: ${toastCheck.result.value}`);
|
||||
console.log('[wechat-browser] Draft saved!');
|
||||
} else {
|
||||
console.log('[wechat-browser] Article composed (preview mode). Add --submit to save as draft.');
|
||||
@@ -641,7 +733,7 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
|
||||
}
|
||||
|
||||
function printUsage(): never {
|
||||
console.log(`Post image-text (图文) to WeChat Official Account
|
||||
console.log(`Post image-text (贴图) to WeChat Official Account
|
||||
|
||||
Usage:
|
||||
npx -y bun wechat-browser.ts [options]
|
||||
|
||||
@@ -26,6 +26,7 @@ Posts text, images, videos, and long-form articles to X via real Chrome browser
|
||||
| `scripts/md-to-html.ts` | Markdown → HTML conversion |
|
||||
| `scripts/copy-to-clipboard.ts` | Copy content to clipboard |
|
||||
| `scripts/paste-from-clipboard.ts` | Send real paste keystroke |
|
||||
| `scripts/check-paste-permissions.ts` | Verify environment & permissions |
|
||||
|
||||
## Preferences (EXTEND.md)
|
||||
|
||||
@@ -55,7 +56,7 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md" && echo "user"
|
||||
│ Not found │ Use defaults │
|
||||
└───────────┴───────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
**EXTEND.md Supports**: Default Chrome profile | Auto-submit preference
|
||||
**EXTEND.md Supports**: Default Chrome profile
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -63,6 +64,28 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md" && echo "user"
|
||||
- `bun` runtime
|
||||
- First run: log in to X manually (session saved)
|
||||
|
||||
## Pre-flight Check (Optional)
|
||||
|
||||
Before first use, suggest running the environment check. User can skip if they prefer.
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/check-paste-permissions.ts
|
||||
```
|
||||
|
||||
Checks: Chrome, profile isolation, Bun, Accessibility, clipboard, paste keystroke, Chrome conflicts.
|
||||
|
||||
**If any check fails**, provide fix guidance per item:
|
||||
|
||||
| Check | Fix |
|
||||
|-------|-----|
|
||||
| Chrome | Install Chrome or set `X_BROWSER_CHROME_PATH` env var |
|
||||
| Profile dir | Ensure `~/.local/share/x-browser-profile` is writable |
|
||||
| Bun runtime | `curl -fsSL https://bun.sh/install \| bash` |
|
||||
| Accessibility (macOS) | System Settings → Privacy & Security → Accessibility → enable terminal app |
|
||||
| Clipboard copy | Ensure Swift/AppKit available (macOS Xcode CLI tools: `xcode-select --install`) |
|
||||
| Paste keystroke (macOS) | Same as Accessibility fix above |
|
||||
| Paste keystroke (Linux) | Install `xdotool` (X11) or `ydotool` (Wayland) |
|
||||
|
||||
## References
|
||||
|
||||
- **Regular Posts**: See `references/regular-posts.md` for manual workflow, troubleshooting, and technical details
|
||||
@@ -75,8 +98,7 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md" && echo "user"
|
||||
Text + up to 4 images.
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png # Preview
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png --submit # Post
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
@@ -84,9 +106,10 @@ npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png --subm
|
||||
|-----------|-------------|
|
||||
| `<text>` | Post content (positional) |
|
||||
| `--image <path>` | Image file (repeatable, max 4) |
|
||||
| `--submit` | Post (default: preview) |
|
||||
| `--profile <dir>` | Custom Chrome profile |
|
||||
|
||||
**Note**: Script opens browser with content filled in. User reviews and publishes manually.
|
||||
|
||||
---
|
||||
|
||||
## Video Posts
|
||||
@@ -94,8 +117,7 @@ npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png --subm
|
||||
Text + video file.
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Check this out!" --video ./clip.mp4 # Preview
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Amazing content" --video ./demo.mp4 --submit # Post
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Check this out!" --video ./clip.mp4
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
@@ -103,9 +125,10 @@ npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Amazing content" --video ./demo.mp4
|
||||
|-----------|-------------|
|
||||
| `<text>` | Post content (positional) |
|
||||
| `--video <path>` | Video file (MP4, MOV, WebM) |
|
||||
| `--submit` | Post (default: preview) |
|
||||
| `--profile <dir>` | Custom Chrome profile |
|
||||
|
||||
**Note**: Script opens browser with content filled in. User reviews and publishes manually.
|
||||
|
||||
**Limits**: Regular 140s max, Premium 60min. Processing: 30-60s.
|
||||
|
||||
---
|
||||
@@ -115,8 +138,7 @@ npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Amazing content" --video ./demo.mp4
|
||||
Quote an existing tweet with comment.
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-quote.ts https://x.com/user/status/123 "Great insight!" # Preview
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-quote.ts https://x.com/user/status/123 "I agree!" --submit # Post
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-quote.ts https://x.com/user/status/123 "Great insight!"
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
@@ -124,9 +146,10 @@ npx -y bun ${SKILL_DIR}/scripts/x-quote.ts https://x.com/user/status/123 "I agre
|
||||
|-----------|-------------|
|
||||
| `<tweet-url>` | URL to quote (positional) |
|
||||
| `<comment>` | Comment text (positional, optional) |
|
||||
| `--submit` | Post (default: preview) |
|
||||
| `--profile <dir>` | Custom Chrome profile |
|
||||
|
||||
**Note**: Script opens browser with content filled in. User reviews and publishes manually.
|
||||
|
||||
---
|
||||
|
||||
## X Articles
|
||||
@@ -134,9 +157,8 @@ npx -y bun ${SKILL_DIR}/scripts/x-quote.ts https://x.com/user/status/123 "I agre
|
||||
Long-form Markdown articles (requires X Premium).
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md # Preview
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --cover ./cover.jpg # With cover
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --submit # Publish
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md
|
||||
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --cover ./cover.jpg
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
@@ -145,16 +167,29 @@ npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --submit #
|
||||
| `<markdown>` | Markdown file (positional) |
|
||||
| `--cover <path>` | Cover image |
|
||||
| `--title <text>` | Override title |
|
||||
| `--submit` | Publish (default: preview) |
|
||||
|
||||
**Frontmatter**: `title`, `cover_image` supported in YAML front matter.
|
||||
|
||||
**Note**: Script opens browser with article filled in. User reviews and publishes manually.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Chrome debug port not ready
|
||||
|
||||
If a script fails with `Chrome debug port not ready` or `Unable to connect`, kill existing Chrome CDP instances first, then retry:
|
||||
|
||||
```bash
|
||||
pkill -f "Chrome.*remote-debugging-port" 2>/dev/null; pkill -f "Chromium.*remote-debugging-port" 2>/dev/null; sleep 2
|
||||
```
|
||||
|
||||
**Important**: This should be done automatically — when encountering this error, kill Chrome CDP instances and retry the command without asking the user.
|
||||
|
||||
## Notes
|
||||
|
||||
- First run: manual login required (session persists)
|
||||
- Always preview before `--submit`
|
||||
- All scripts only fill content into the browser, user must review and publish manually
|
||||
- Cross-platform: macOS, Linux, Windows
|
||||
|
||||
## Extension Support
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "baoyu-post-to-x-scripts",
|
||||
"dependencies": {
|
||||
"front-matter": "^4.0.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^15.0.6",
|
||||
"remark-cjk-friendly": "^1.1.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-stringify": "^11.0.0",
|
||||
"unified": "^11.0.5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"front-matter": ["front-matter@4.0.2", "", { "dependencies": { "js-yaml": "^3.13.1" } }, "sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg=="],
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
|
||||
|
||||
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
|
||||
|
||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
|
||||
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||
|
||||
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
|
||||
|
||||
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
|
||||
|
||||
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
|
||||
|
||||
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
|
||||
|
||||
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||
|
||||
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||
|
||||
"micromark-extension-cjk-friendly": ["micromark-extension-cjk-friendly@1.2.3", "", { "dependencies": { "devlop": "^1.1.0", "micromark-extension-cjk-friendly-util": "2.1.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-gRzVLUdjXBLX6zNPSnHGDoo+ZTp5zy+MZm0g3sv+3chPXY7l9gW+DnrcHcZh/jiPR6MjPKO4AEJNp4Aw6V9z5Q=="],
|
||||
|
||||
"micromark-extension-cjk-friendly-util": ["micromark-extension-cjk-friendly-util@2.1.1", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "micromark-util-character": "^2.1.1", "micromark-util-symbol": "^2.0.1" } }, "sha512-egs6+12JU2yutskHY55FyR48ZiEcFOJFyk9rsiyIhcJ6IvWB6ABBqVrBw8IobqJTDZ/wdSr9eoXDPb5S2nW1bg=="],
|
||||
|
||||
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
|
||||
|
||||
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
|
||||
|
||||
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
|
||||
|
||||
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
|
||||
|
||||
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
|
||||
|
||||
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||
|
||||
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
|
||||
|
||||
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
|
||||
|
||||
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
|
||||
|
||||
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
|
||||
|
||||
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
|
||||
|
||||
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
||||
|
||||
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
|
||||
|
||||
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
|
||||
|
||||
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
|
||||
|
||||
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
|
||||
|
||||
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
|
||||
|
||||
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
|
||||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"remark-cjk-friendly": ["remark-cjk-friendly@1.2.3", "", { "dependencies": { "micromark-extension-cjk-friendly": "1.2.3" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-UvAgxwlNk+l9Oqgl/9MWK2eWRS7zgBW/nXX9AthV7nd/3lNejF138E7Xbmk9Zs4WjTJGs721r7fAEc7tNFoH7g=="],
|
||||
|
||||
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
|
||||
|
||||
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
|
||||
|
||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
|
||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||
|
||||
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||
|
||||
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||
|
||||
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
|
||||
|
||||
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
|
||||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { findChromeExecutable, CHROME_CANDIDATES_FULL, getDefaultProfileDir } from './x-utils.js';
|
||||
|
||||
interface CheckResult {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
const results: CheckResult[] = [];
|
||||
|
||||
function log(label: string, ok: boolean, detail: string): void {
|
||||
results.push({ name: label, ok, detail });
|
||||
const icon = ok ? '✅' : '❌';
|
||||
console.log(`${icon} ${label}: ${detail}`);
|
||||
}
|
||||
|
||||
function warn(label: string, detail: string): void {
|
||||
results.push({ name: label, ok: true, detail });
|
||||
console.log(`⚠️ ${label}: ${detail}`);
|
||||
}
|
||||
|
||||
async function checkChrome(): Promise<void> {
|
||||
const chromePath = findChromeExecutable(CHROME_CANDIDATES_FULL);
|
||||
if (chromePath) {
|
||||
log('Chrome', true, chromePath);
|
||||
} else {
|
||||
log('Chrome', false, 'Not found. Set X_BROWSER_CHROME_PATH env var or install Chrome.');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkProfileIsolation(): Promise<void> {
|
||||
const profileDir = getDefaultProfileDir();
|
||||
const userChromeDir = process.platform === 'darwin'
|
||||
? path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome')
|
||||
: process.platform === 'win32'
|
||||
? path.join(os.homedir(), 'AppData', 'Local', 'Google', 'Chrome', 'User Data')
|
||||
: path.join(os.homedir(), '.config', 'google-chrome');
|
||||
|
||||
const isIsolated = !profileDir.startsWith(userChromeDir);
|
||||
log('Profile isolation', isIsolated, `Skill profile: ${profileDir}`);
|
||||
|
||||
if (isIsolated) {
|
||||
const exists = fs.existsSync(profileDir);
|
||||
if (exists) {
|
||||
log('Profile dir', true, 'Exists and accessible');
|
||||
} else {
|
||||
try {
|
||||
fs.mkdirSync(profileDir, { recursive: true });
|
||||
log('Profile dir', true, 'Created successfully');
|
||||
} catch (e) {
|
||||
log('Profile dir', false, `Cannot create: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAccessibility(): Promise<void> {
|
||||
if (process.platform !== 'darwin') {
|
||||
log('Accessibility', true, `Skipped (not macOS, platform: ${process.platform})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = spawnSync('osascript', ['-e', `
|
||||
tell application "System Events"
|
||||
set frontApp to name of first application process whose frontmost is true
|
||||
return frontApp
|
||||
end tell
|
||||
`], { stdio: 'pipe', timeout: 10_000 });
|
||||
|
||||
if (result.status === 0) {
|
||||
const app = result.stdout?.toString().trim();
|
||||
log('Accessibility (System Events)', true, `Frontmost app: ${app}`);
|
||||
} else {
|
||||
const stderr = result.stderr?.toString().trim() || '';
|
||||
if (stderr.includes('not allowed assistive access') || stderr.includes('1002')) {
|
||||
log('Accessibility (System Events)', false,
|
||||
'Denied. Grant access: System Settings → Privacy & Security → Accessibility → enable your terminal app');
|
||||
} else {
|
||||
log('Accessibility (System Events)', false, `Failed: ${stderr}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkClipboardCopy(): Promise<void> {
|
||||
if (process.platform !== 'darwin') {
|
||||
log('Clipboard copy (image)', true, `Skipped (not macOS)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'x-check-'));
|
||||
try {
|
||||
const testPng = path.join(tmpDir, 'test.png');
|
||||
const swiftSrc = `import AppKit
|
||||
import Foundation
|
||||
let size = NSSize(width: 2, height: 2)
|
||||
let image = NSImage(size: size)
|
||||
image.lockFocus()
|
||||
NSColor.red.set()
|
||||
NSBezierPath.fill(NSRect(origin: .zero, size: size))
|
||||
image.unlockFocus()
|
||||
guard let tiff = image.tiffRepresentation,
|
||||
let rep = NSBitmapImageRep(data: tiff),
|
||||
let png = rep.representation(using: .png, properties: [:]) else {
|
||||
FileHandle.standardError.write("Failed to create test PNG\\n".data(using: .utf8)!)
|
||||
exit(1)
|
||||
}
|
||||
try png.write(to: URL(fileURLWithPath: CommandLine.arguments[1]))
|
||||
`;
|
||||
const genScript = path.join(tmpDir, 'gen.swift');
|
||||
await writeFile(genScript, swiftSrc, 'utf8');
|
||||
const genResult = spawnSync('swift', [genScript, testPng], { stdio: 'pipe', timeout: 30_000 });
|
||||
if (genResult.status !== 0) {
|
||||
log('Clipboard copy (image)', false, `Cannot create test image: ${genResult.stderr?.toString().trim()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const clipSrc = `import AppKit
|
||||
import Foundation
|
||||
guard let image = NSImage(contentsOfFile: CommandLine.arguments[1]) else {
|
||||
FileHandle.standardError.write("Failed to load image\\n".data(using: .utf8)!)
|
||||
exit(1)
|
||||
}
|
||||
let pb = NSPasteboard.general
|
||||
pb.clearContents()
|
||||
if !pb.writeObjects([image]) {
|
||||
FileHandle.standardError.write("Failed to write to clipboard\\n".data(using: .utf8)!)
|
||||
exit(1)
|
||||
}
|
||||
`;
|
||||
const clipScript = path.join(tmpDir, 'clip.swift');
|
||||
await writeFile(clipScript, clipSrc, 'utf8');
|
||||
const clipResult = spawnSync('swift', [clipScript, testPng], { stdio: 'pipe', timeout: 30_000 });
|
||||
if (clipResult.status === 0) {
|
||||
log('Clipboard copy (image)', true, 'Can copy image to clipboard via Swift/AppKit');
|
||||
} else {
|
||||
log('Clipboard copy (image)', false, `Failed: ${clipResult.stderr?.toString().trim()}`);
|
||||
}
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function checkPasteKeystroke(): Promise<void> {
|
||||
if (process.platform === 'darwin') {
|
||||
const result = spawnSync('osascript', ['-e', `
|
||||
tell application "System Events"
|
||||
-- Dry run: just check we CAN query key sending capability
|
||||
set canSend to true
|
||||
return canSend
|
||||
end tell
|
||||
`], { stdio: 'pipe', timeout: 10_000 });
|
||||
|
||||
if (result.status === 0) {
|
||||
log('Paste keystroke (osascript)', true, 'System Events can send keystrokes');
|
||||
} else {
|
||||
const stderr = result.stderr?.toString().trim() || '';
|
||||
log('Paste keystroke (osascript)', false, `Cannot send keystrokes: ${stderr}`);
|
||||
}
|
||||
} else if (process.platform === 'linux') {
|
||||
const xdotool = spawnSync('which', ['xdotool'], { stdio: 'pipe' });
|
||||
const ydotool = spawnSync('which', ['ydotool'], { stdio: 'pipe' });
|
||||
if (xdotool.status === 0) {
|
||||
log('Paste keystroke', true, 'xdotool available (X11)');
|
||||
} else if (ydotool.status === 0) {
|
||||
log('Paste keystroke', true, 'ydotool available (Wayland)');
|
||||
} else {
|
||||
log('Paste keystroke', false, 'No tool found. Install xdotool (X11) or ydotool (Wayland).');
|
||||
}
|
||||
} else if (process.platform === 'win32') {
|
||||
log('Paste keystroke', true, 'Windows uses PowerShell SendKeys (built-in)');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkBun(): Promise<void> {
|
||||
const result = spawnSync('npx', ['-y', 'bun', '--version'], { stdio: 'pipe', timeout: 30_000 });
|
||||
if (result.status === 0) {
|
||||
log('Bun runtime', true, `v${result.stdout?.toString().trim()}`);
|
||||
} else {
|
||||
log('Bun runtime', false, 'Cannot run bun. Install: curl -fsSL https://bun.sh/install | bash');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkRunningChromeConflict(): Promise<void> {
|
||||
if (process.platform !== 'darwin') return;
|
||||
|
||||
const result = spawnSync('pgrep', ['-f', 'Google Chrome'], { stdio: 'pipe' });
|
||||
const pids = result.stdout?.toString().trim().split('\n').filter(Boolean) || [];
|
||||
|
||||
if (pids.length > 0) {
|
||||
warn('Running Chrome instances', `${pids.length} Chrome process(es) detected. The skill uses --user-data-dir for isolation, so this is safe. Paste keystroke targets Chrome by app name (minor risk if multiple Chrome windows visible).`);
|
||||
} else {
|
||||
log('Running Chrome instances', true, 'No existing Chrome processes');
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log('=== baoyu-post-to-x: Permission & Environment Check ===\n');
|
||||
|
||||
await checkChrome();
|
||||
await checkProfileIsolation();
|
||||
await checkBun();
|
||||
await checkAccessibility();
|
||||
await checkClipboardCopy();
|
||||
await checkPasteKeystroke();
|
||||
await checkRunningChromeConflict();
|
||||
|
||||
console.log('\n--- Summary ---');
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
if (failed.length === 0) {
|
||||
console.log('All checks passed. Ready to post to X.');
|
||||
} else {
|
||||
console.log(`${failed.length} issue(s) found:`);
|
||||
for (const f of failed) {
|
||||
console.log(` ❌ ${f.name}: ${f.detail}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
await main().catch((err) => {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -228,25 +228,25 @@ async function copyHtmlLinux(htmlFilePath: string): Promise<void> {
|
||||
}
|
||||
|
||||
async function copyImageWindows(imagePath: string): Promise<void> {
|
||||
const escaped = imagePath.replace(/'/g, "''");
|
||||
const ps = [
|
||||
'param([string]$Path)',
|
||||
'Add-Type -AssemblyName System.Windows.Forms',
|
||||
'Add-Type -AssemblyName System.Drawing',
|
||||
'$img = [System.Drawing.Image]::FromFile($Path)',
|
||||
`$img = [System.Drawing.Image]::FromFile('${escaped}')`,
|
||||
'[System.Windows.Forms.Clipboard]::SetImage($img)',
|
||||
'$img.Dispose()',
|
||||
].join('; ');
|
||||
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps, '-Path', imagePath]);
|
||||
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps]);
|
||||
}
|
||||
|
||||
async function copyHtmlWindows(htmlFilePath: string): Promise<void> {
|
||||
const escaped = htmlFilePath.replace(/'/g, "''");
|
||||
const ps = [
|
||||
'param([string]$Path)',
|
||||
'Add-Type -AssemblyName System.Windows.Forms',
|
||||
'$html = Get-Content -Raw -LiteralPath $Path',
|
||||
`$html = Get-Content -Raw -LiteralPath '${escaped}'`,
|
||||
'[System.Windows.Forms.Clipboard]::SetText($html, [System.Windows.Forms.TextDataFormat]::Html)',
|
||||
].join('; ');
|
||||
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps, '-Path', htmlFilePath]);
|
||||
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps]);
|
||||
}
|
||||
|
||||
async function copyImageToClipboard(imagePathInput: string): Promise<void> {
|
||||
|
||||
@@ -7,6 +7,14 @@ import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import frontMatter from 'front-matter';
|
||||
import hljs from 'highlight.js/lib/common';
|
||||
import { Lexer, Marked, type RendererObject, type Tokens } from 'marked';
|
||||
import { unified } from 'unified';
|
||||
import remarkCjkFriendly from 'remark-cjk-friendly';
|
||||
import remarkParse from 'remark-parse';
|
||||
import remarkStringify from 'remark-stringify';
|
||||
|
||||
interface ImageInfo {
|
||||
placeholder: string;
|
||||
localPath: string;
|
||||
@@ -22,25 +30,80 @@ interface ParsedMarkdown {
|
||||
totalBlocks: number;
|
||||
}
|
||||
|
||||
function parseFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } {
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
||||
if (!match) return { frontmatter: {}, body: content };
|
||||
type FrontmatterFields = Record<string, unknown>;
|
||||
|
||||
const frontmatter: Record<string, string> = {};
|
||||
const lines = match[1]!.split('\n');
|
||||
for (const line of lines) {
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
const key = line.slice(0, colonIdx).trim();
|
||||
let value = line.slice(colonIdx + 1).trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
function parseFrontmatter(content: string): { frontmatter: FrontmatterFields; body: string } {
|
||||
try {
|
||||
const parsed = frontMatter<FrontmatterFields>(content);
|
||||
return {
|
||||
frontmatter: parsed.attributes ?? {},
|
||||
body: parsed.body,
|
||||
};
|
||||
} catch {
|
||||
return { frontmatter: {}, body: content };
|
||||
}
|
||||
}
|
||||
|
||||
function stripWrappingQuotes(value: string): string {
|
||||
if (!value) return value;
|
||||
const doubleQuoted = value.startsWith('"') && value.endsWith('"');
|
||||
const singleQuoted = value.startsWith("'") && value.endsWith("'");
|
||||
const cjkDoubleQuoted = value.startsWith('\u201c') && value.endsWith('\u201d');
|
||||
const cjkSingleQuoted = value.startsWith('\u2018') && value.endsWith('\u2019');
|
||||
if (doubleQuoted || singleQuoted || cjkDoubleQuoted || cjkSingleQuoted) {
|
||||
return value.slice(1, -1).trim();
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function toFrontmatterString(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') {
|
||||
return stripWrappingQuotes(value);
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pickFirstString(frontmatter: FrontmatterFields, keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = toFrontmatterString(frontmatter[key]);
|
||||
if (value) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findCoverImageNearMarkdown(baseDir: string): string | null {
|
||||
const candidateDirs = [baseDir, path.join(baseDir, 'imgs')];
|
||||
const coverPattern = /^cover\.(png|jpe?g|webp)$/i;
|
||||
|
||||
for (const dir of candidateDirs) {
|
||||
try {
|
||||
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
frontmatter[key] = value;
|
||||
|
||||
const match = fs.readdirSync(dir).find((entry) => coverPattern.test(entry));
|
||||
if (match) {
|
||||
return path.join(dir, match);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return { frontmatter, body: match[2]! };
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractTitleFromMarkdown(markdown: string): string {
|
||||
const tokens = Lexer.lex(markdown, { gfm: true, breaks: true });
|
||||
for (const token of tokens) {
|
||||
if (token.type === 'heading' && token.depth === 1) {
|
||||
return stripWrappingQuotes(token.text);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function downloadFile(url: string, destPath: string): Promise<void> {
|
||||
@@ -116,141 +179,99 @@ function escapeHtml(text: string): string {
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function highlightCode(code: string, lang: string): string {
|
||||
try {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
|
||||
}
|
||||
return hljs.highlightAuto(code).value;
|
||||
} catch {
|
||||
return escapeHtml(code);
|
||||
}
|
||||
}
|
||||
|
||||
function preprocessCjkMarkdown(markdown: string): string {
|
||||
try {
|
||||
const processor = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkCjkFriendly)
|
||||
.use(remarkStringify);
|
||||
|
||||
const result = String(processor.processSync(markdown));
|
||||
return result.replace(/&#x([0-9A-Fa-f]+);/g, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)));
|
||||
} catch {
|
||||
return markdown;
|
||||
}
|
||||
}
|
||||
|
||||
function convertMarkdownToHtml(markdown: string, imageCallback: (src: string, alt: string) => string): { html: string; totalBlocks: number } {
|
||||
const lines = markdown.split('\n');
|
||||
const blocks: string[] = [];
|
||||
let inCodeBlock = false;
|
||||
let codeBlockContent: string[] = [];
|
||||
let inList = false;
|
||||
let listItems: string[] = [];
|
||||
let listType: 'ul' | 'ol' = 'ul';
|
||||
const preprocessedMarkdown = preprocessCjkMarkdown(markdown);
|
||||
const blockTokens = Lexer.lex(preprocessedMarkdown, { gfm: true, breaks: true });
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length > 0) {
|
||||
const tag = listType === 'ol' ? 'ol' : 'ul';
|
||||
blocks.push(`<${tag}>${listItems.map((item) => `<li>${item}</li>`).join('')}</${tag}>`);
|
||||
listItems = [];
|
||||
inList = false;
|
||||
}
|
||||
const renderer: RendererObject = {
|
||||
heading({ depth, tokens }: Tokens.Heading): string {
|
||||
if (depth === 1) {
|
||||
return '';
|
||||
}
|
||||
return `<h2>${this.parser.parseInline(tokens)}</h2>`;
|
||||
},
|
||||
|
||||
paragraph({ tokens }: Tokens.Paragraph): string {
|
||||
const text = this.parser.parseInline(tokens).trim();
|
||||
if (!text) return '';
|
||||
return `<p>${text}</p>`;
|
||||
},
|
||||
|
||||
blockquote({ tokens }: Tokens.Blockquote): string {
|
||||
return `<blockquote>${this.parser.parse(tokens)}</blockquote>`;
|
||||
},
|
||||
|
||||
code({ text, lang = '' }: Tokens.Code): string {
|
||||
const language = lang.split(/\s+/)[0]!.toLowerCase();
|
||||
const source = text.replace(/\n$/, '');
|
||||
const highlighted = highlightCode(source, language).replace(/\n/g, '<br>');
|
||||
const label = language ? `<strong>[${escapeHtml(language)}]</strong><br>` : '';
|
||||
return `<blockquote>${label}${highlighted}</blockquote>`;
|
||||
},
|
||||
|
||||
image({ href, text }: Tokens.Image): string {
|
||||
if (!href) return '';
|
||||
return imageCallback(href, text ?? '');
|
||||
},
|
||||
|
||||
link({ href, title, tokens, text }: Tokens.Link): string {
|
||||
const label = tokens?.length ? this.parser.parseInline(tokens) : escapeHtml(text || href || '');
|
||||
if (!href) return label;
|
||||
|
||||
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
|
||||
return `<a href="${escapeHtml(href)}"${titleAttr} rel="noopener noreferrer nofollow">${label}</a>`;
|
||||
},
|
||||
};
|
||||
|
||||
const processInline = (text: string): string => {
|
||||
// Bold
|
||||
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
text = text.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
||||
const parser = new Marked({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
});
|
||||
parser.use({ renderer });
|
||||
|
||||
// Italic
|
||||
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
text = text.replace(/_(.+?)_/g, '<em>$1</em>');
|
||||
|
||||
// Links
|
||||
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
||||
|
||||
// Inline code
|
||||
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
|
||||
// Code block
|
||||
if (line.startsWith('```')) {
|
||||
if (inCodeBlock) {
|
||||
// X doesn't support <pre><code>, convert to blockquote
|
||||
const codeContent = codeBlockContent.map((l) => escapeHtml(l)).join('<br>');
|
||||
blocks.push(`<blockquote>${codeContent}</blockquote>`);
|
||||
codeBlockContent = [];
|
||||
inCodeBlock = false;
|
||||
} else {
|
||||
flushList();
|
||||
inCodeBlock = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inCodeBlock) {
|
||||
codeBlockContent.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Empty line
|
||||
if (line.trim() === '') {
|
||||
flushList();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Image
|
||||
const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/);
|
||||
if (imgMatch) {
|
||||
flushList();
|
||||
const placeholder = imageCallback(imgMatch[2]!, imgMatch[1]!);
|
||||
blocks.push(`<p>${placeholder}</p>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Heading (H1 is title, skip it; H2-H6 become H2)
|
||||
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
flushList();
|
||||
const level = headingMatch[1]!.length;
|
||||
if (level === 1) continue; // Skip H1, it's the title
|
||||
blocks.push(`<h2>${processInline(headingMatch[2]!)}</h2>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
if (line.startsWith('> ')) {
|
||||
flushList();
|
||||
blocks.push(`<blockquote>${processInline(line.slice(2))}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
const ulMatch = line.match(/^[-*]\s+(.+)$/);
|
||||
if (ulMatch) {
|
||||
if (!inList || listType !== 'ul') {
|
||||
flushList();
|
||||
inList = true;
|
||||
listType = 'ul';
|
||||
}
|
||||
listItems.push(processInline(ulMatch[1]!));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
const olMatch = line.match(/^\d+\.\s+(.+)$/);
|
||||
if (olMatch) {
|
||||
if (!inList || listType !== 'ol') {
|
||||
flushList();
|
||||
inList = true;
|
||||
listType = 'ol';
|
||||
}
|
||||
listItems.push(processInline(olMatch[1]!));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (/^[-*_]{3,}\s*$/.test(line)) {
|
||||
flushList();
|
||||
blocks.push('<hr>');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular paragraph
|
||||
flushList();
|
||||
blocks.push(`<p>${processInline(line)}</p>`);
|
||||
const rendered = parser.parse(preprocessedMarkdown);
|
||||
if (typeof rendered !== 'string') {
|
||||
throw new Error('Unexpected async markdown parse result');
|
||||
}
|
||||
|
||||
flushList();
|
||||
const totalBlocks = blockTokens.filter((token) => {
|
||||
if (token.type === 'space') return false;
|
||||
if (token.type === 'heading' && token.depth === 1) return false;
|
||||
return true;
|
||||
}).length;
|
||||
|
||||
return {
|
||||
html: blocks.join('\n'),
|
||||
totalBlocks: blocks.length,
|
||||
html: rendered,
|
||||
totalBlocks,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -266,59 +287,58 @@ export async function parseMarkdown(
|
||||
|
||||
const { frontmatter, body } = parseFrontmatter(content);
|
||||
|
||||
// Extract title from frontmatter, option, or first H1
|
||||
let title = options?.title ?? frontmatter.title ?? '';
|
||||
let title = stripWrappingQuotes(options?.title ?? '') || pickFirstString(frontmatter, ['title']) || '';
|
||||
if (!title) {
|
||||
const h1Match = body.match(/^#\s+(.+)$/m);
|
||||
if (h1Match) title = h1Match[1]!;
|
||||
title = extractTitleFromMarkdown(body);
|
||||
}
|
||||
if (!title) {
|
||||
title = path.basename(markdownPath, path.extname(markdownPath));
|
||||
}
|
||||
|
||||
// Extract cover image from frontmatter or option
|
||||
let coverImagePath = options?.coverImage ?? frontmatter.cover_image ?? frontmatter.coverImage ?? frontmatter.cover ?? frontmatter.image ?? frontmatter.featureImage ?? frontmatter.feature_image ?? null;
|
||||
let coverImagePath = stripWrappingQuotes(options?.coverImage ?? '') || pickFirstString(frontmatter, [
|
||||
'cover_image',
|
||||
'coverImage',
|
||||
'cover',
|
||||
'image',
|
||||
'featureImage',
|
||||
'feature_image',
|
||||
]) || null;
|
||||
if (!coverImagePath) {
|
||||
coverImagePath = findCoverImageNearMarkdown(baseDir);
|
||||
}
|
||||
|
||||
const images: Array<{ src: string; alt: string; blockIndex: number }> = [];
|
||||
let imageCounter = 0;
|
||||
|
||||
const { html, totalBlocks } = convertMarkdownToHtml(body, (src, alt) => {
|
||||
const placeholder = `XIMGPH_${++imageCounter}`;
|
||||
const currentBlockIndex = images.length; // Will be set properly after HTML generation
|
||||
|
||||
images.push({ src, alt, blockIndex: -1 }); // blockIndex set later
|
||||
images.push({ src, alt, blockIndex: -1 });
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// Update block indices by finding placeholders in HTML
|
||||
const htmlLines = html.split('\n');
|
||||
let blockIdx = 0;
|
||||
for (const line of htmlLines) {
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const placeholder = `XIMGPH_${i + 1}`;
|
||||
if (line.includes(placeholder)) {
|
||||
images[i]!.blockIndex = blockIdx;
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const placeholder = `XIMGPH_${i + 1}`;
|
||||
for (let lineIndex = 0; lineIndex < htmlLines.length; lineIndex++) {
|
||||
const regex = new RegExp(`\\b${placeholder}\\b`);
|
||||
if (regex.test(htmlLines[lineIndex]!)) {
|
||||
images[i]!.blockIndex = lineIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
blockIdx++;
|
||||
}
|
||||
|
||||
// Resolve image paths (download remote, resolve relative)
|
||||
const contentImages: ImageInfo[] = [];
|
||||
let isFirstImage = true;
|
||||
let coverPlaceholder: string | null = null;
|
||||
let firstImageAsCover: string | null = null;
|
||||
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const img = images[i]!;
|
||||
const localPath = await resolveImagePath(img.src, baseDir, tempDir);
|
||||
|
||||
// First image becomes cover if no cover specified
|
||||
if (isFirstImage && !coverImagePath) {
|
||||
coverImagePath = localPath;
|
||||
coverPlaceholder = `XIMGPH_${i + 1}`;
|
||||
isFirstImage = false;
|
||||
// Don't add to contentImages, it's the cover
|
||||
continue;
|
||||
if (i === 0 && !coverImagePath) {
|
||||
firstImageAsCover = localPath;
|
||||
}
|
||||
|
||||
isFirstImage = false;
|
||||
contentImages.push({
|
||||
placeholder: `XIMGPH_${i + 1}`,
|
||||
localPath,
|
||||
@@ -327,17 +347,13 @@ export async function parseMarkdown(
|
||||
});
|
||||
}
|
||||
|
||||
// Remove cover placeholder from HTML if first image was used as cover
|
||||
let finalHtml = html;
|
||||
if (coverPlaceholder) {
|
||||
// Remove the placeholder and its containing <p> tag
|
||||
finalHtml = finalHtml.replace(new RegExp(`<p>${coverPlaceholder}</p>\\n?`, 'g'), '');
|
||||
}
|
||||
const finalHtml = html.replace(/\n{3,}/g, '\n\n').trim();
|
||||
|
||||
// Resolve cover image path
|
||||
let resolvedCoverImage: string | null = null;
|
||||
if (coverImagePath) {
|
||||
resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir);
|
||||
} else if (firstImageAsCover) {
|
||||
resolvedCoverImage = firstImageAsCover;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "baoyu-post-to-x-scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"front-matter": "^4.0.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^15.0.6",
|
||||
"remark-cjk-friendly": "^1.1.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-stringify": "^11.0.0",
|
||||
"unified": "^11.0.5"
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,25 @@ interface ArticleOptions {
|
||||
chromePath?: string;
|
||||
}
|
||||
|
||||
async function findExistingDebugPort(profileDir: string): Promise<number | null> {
|
||||
const portFile = path.join(profileDir, 'DevToolsActivePort');
|
||||
if (!fs.existsSync(portFile)) return null;
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(portFile, 'utf-8').trim();
|
||||
if (!content) return null;
|
||||
const [portLine] = content.split(/\r?\n/);
|
||||
const port = Number(portLine);
|
||||
if (!Number.isFinite(port) || port <= 0) return null;
|
||||
|
||||
// Verify the port is actually active.
|
||||
await waitForChromeDebugPort(port, 1500, { includeLastError: true });
|
||||
return port;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||
const { markdownPath, submit = false, profileDir = getDefaultProfileDir() } = options;
|
||||
|
||||
@@ -83,28 +102,39 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||
if (!chromePath) throw new Error('Chrome not found');
|
||||
|
||||
await mkdir(profileDir, { recursive: true });
|
||||
const port = await getFreePort();
|
||||
const existingPort = await findExistingDebugPort(profileDir);
|
||||
const port = existingPort ?? await getFreePort();
|
||||
|
||||
console.log(`[x-article] Launching Chrome...`);
|
||||
const chrome = spawn(chromePath, [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profileDir}`,
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--start-maximized',
|
||||
X_ARTICLES_URL,
|
||||
], { stdio: 'ignore' });
|
||||
if (existingPort) {
|
||||
console.log(`[x-article] Reusing existing Chrome instance on port ${port}`);
|
||||
} else {
|
||||
console.log(`[x-article] Launching Chrome...`);
|
||||
const chromeArgs = [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profileDir}`,
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--start-maximized',
|
||||
X_ARTICLES_URL,
|
||||
];
|
||||
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;
|
||||
|
||||
try {
|
||||
const wsUrl = await waitForChromeDebugPort(port, 30_000);
|
||||
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
||||
cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 30_000 });
|
||||
|
||||
// Get page target
|
||||
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
||||
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('x.com'));
|
||||
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.startsWith(X_ARTICLES_URL));
|
||||
|
||||
if (!pageTarget) {
|
||||
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: X_ARTICLES_URL });
|
||||
@@ -409,15 +439,23 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||
|
||||
console.log('[x-article] Checking for placeholders in content...');
|
||||
for (const img of parsed.contentImages) {
|
||||
if (editorContent.result.value.includes(img.placeholder)) {
|
||||
// Use regex for exact match (not followed by digit, e.g., XIMGPH_1 should not match XIMGPH_10)
|
||||
const regex = new RegExp(img.placeholder + '(?!\\d)');
|
||||
if (regex.test(editorContent.result.value)) {
|
||||
console.log(`[x-article] Found: ${img.placeholder}`);
|
||||
} else {
|
||||
console.log(`[x-article] NOT found: ${img.placeholder}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Process images in sequential order (1, 2, 3, ...)
|
||||
const sortedImages = [...parsed.contentImages].sort((a, b) => a.blockIndex - b.blockIndex);
|
||||
// Process images in XIMGPH order (1, 2, 3, ...) regardless of blockIndex
|
||||
const getPlaceholderIndex = (placeholder: string): number => {
|
||||
const match = placeholder.match(/XIMGPH_(\d+)/);
|
||||
return match ? Number(match[1]) : Number.POSITIVE_INFINITY;
|
||||
};
|
||||
const sortedImages = [...parsed.contentImages].sort(
|
||||
(a, b) => getPlaceholderIndex(a.placeholder) - getPlaceholderIndex(b.placeholder),
|
||||
);
|
||||
|
||||
for (let i = 0; i < sortedImages.length; i++) {
|
||||
const img = sortedImages[i]!;
|
||||
@@ -440,22 +478,30 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||
|
||||
while ((node = walker.nextNode())) {
|
||||
const text = node.textContent || '';
|
||||
const idx = text.indexOf(placeholder);
|
||||
if (idx !== -1) {
|
||||
// Found the placeholder - scroll to it first
|
||||
const parentElement = node.parentElement;
|
||||
if (parentElement) {
|
||||
parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
let searchStart = 0;
|
||||
let idx;
|
||||
// Search for exact match (not prefix of longer placeholder like XIMGPH_1 in XIMGPH_10)
|
||||
while ((idx = text.indexOf(placeholder, searchStart)) !== -1) {
|
||||
const afterIdx = idx + placeholder.length;
|
||||
const charAfter = text[afterIdx];
|
||||
// Exact match if next char is not a digit (XIMGPH_1 should not match XIMGPH_10)
|
||||
if (charAfter === undefined || !/\\d/.test(charAfter)) {
|
||||
// Found exact placeholder - scroll to it first
|
||||
const parentElement = node.parentElement;
|
||||
if (parentElement) {
|
||||
parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
// Select it
|
||||
const range = document.createRange();
|
||||
range.setStart(node, idx);
|
||||
range.setEnd(node, idx + placeholder.length);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
return true;
|
||||
// Select it
|
||||
const range = document.createRange();
|
||||
range.setStart(node, idx);
|
||||
range.setEnd(node, idx + placeholder.length);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
return true;
|
||||
}
|
||||
searchStart = afterIdx;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -505,34 +551,63 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||
// Wait for clipboard to be fully ready
|
||||
await sleep(1000);
|
||||
|
||||
// Delete placeholder by pressing Backspace (more reliable than Enter for replacing selection)
|
||||
// Delete placeholder using execCommand (more reliable than keyboard events for DraftJS)
|
||||
console.log(`[x-article] Deleting placeholder...`);
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId });
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId });
|
||||
const deleteResult = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed) return false;
|
||||
// Try execCommand delete first
|
||||
if (document.execCommand('delete', false)) return true;
|
||||
// Fallback: replace selection with empty using insertText
|
||||
document.execCommand('insertText', false, '');
|
||||
return true;
|
||||
})()`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
|
||||
// Wait and verify placeholder is deleted
|
||||
await sleep(500);
|
||||
|
||||
// Check that placeholder is no longer in editor
|
||||
// Check that placeholder is no longer in editor (exact match, not substring)
|
||||
const afterDelete = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
const editor = document.querySelector('.DraftEditor-editorContainer [data-contents="true"]');
|
||||
if (!editor) return true;
|
||||
return !editor.innerText.includes(${JSON.stringify(img.placeholder)});
|
||||
const text = editor.innerText;
|
||||
const placeholder = ${JSON.stringify(img.placeholder)};
|
||||
// Use regex to find exact match (not followed by digit)
|
||||
const regex = new RegExp(placeholder + '(?!\\\\d)');
|
||||
return !regex.test(text);
|
||||
})()`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
|
||||
if (!afterDelete.result.value) {
|
||||
console.warn(`[x-article] Placeholder may not have been deleted, trying again...`);
|
||||
// Try selecting and deleting again
|
||||
console.warn(`[x-article] Placeholder may not have been deleted, trying dispatchEvent...`);
|
||||
// Try selecting and deleting with InputEvent
|
||||
await selectPlaceholder(1);
|
||||
await sleep(300);
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId });
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId });
|
||||
await cdp.send('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');
|
||||
if (!editor) return;
|
||||
editor.focus();
|
||||
// Dispatch beforeinput and input events for deletion
|
||||
const beforeEvent = new InputEvent('beforeinput', { inputType: 'deleteContentBackward', bubbles: true, cancelable: true });
|
||||
editor.dispatchEvent(beforeEvent);
|
||||
const inputEvent = new InputEvent('input', { inputType: 'deleteContentBackward', bubbles: true });
|
||||
editor.dispatchEvent(inputEvent);
|
||||
})()`,
|
||||
}, { sessionId });
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
// Count existing image blocks before paste
|
||||
const imgCountBefore = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {
|
||||
expression: `document.querySelectorAll('section[data-block="true"][contenteditable="false"] img[src^="blob:"]').length`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
|
||||
// Focus editor to ensure cursor is in position
|
||||
await cdp.send('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
@@ -550,9 +625,31 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||
console.warn(`[x-article] Failed to paste image after retries`);
|
||||
}
|
||||
|
||||
// Wait for image to upload
|
||||
console.log(`[x-article] Waiting for upload...`);
|
||||
await sleep(5000);
|
||||
// Verify image appeared in editor
|
||||
console.log(`[x-article] Verifying image upload...`);
|
||||
const expectedImgCount = imgCountBefore.result.value + 1;
|
||||
let imgUploadOk = false;
|
||||
const imgWaitStart = Date.now();
|
||||
while (Date.now() - imgWaitStart < 15_000) {
|
||||
const r = await cdp!.send<{ result: { value: number } }>('Runtime.evaluate', {
|
||||
expression: `document.querySelectorAll('section[data-block="true"][contenteditable="false"] img[src^="blob:"]').length`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
if (r.result.value >= expectedImgCount) {
|
||||
imgUploadOk = true;
|
||||
break;
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
if (imgUploadOk) {
|
||||
console.log(`[x-article] Image upload verified (${expectedImgCount} image block(s))`);
|
||||
} else {
|
||||
console.warn(`[x-article] Image upload not detected after 15s`);
|
||||
if (i === 0) {
|
||||
console.error('[x-article] First image paste failed. Run check-paste-permissions.ts to diagnose.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[x-article] All images processed.');
|
||||
@@ -669,7 +766,8 @@ async function main(): Promise<void> {
|
||||
if (arg === '--title' && args[i + 1]) {
|
||||
title = args[++i];
|
||||
} else if (arg === '--cover' && args[i + 1]) {
|
||||
coverImage = args[++i];
|
||||
const raw = args[++i]!;
|
||||
coverImage = path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);
|
||||
} else if (arg === '--submit') {
|
||||
submit = true;
|
||||
} else if (arg === '--profile' && args[i + 1]) {
|
||||
|
||||
@@ -117,6 +117,12 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Count uploaded images before paste
|
||||
const imgCountBefore = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {
|
||||
expression: `document.querySelectorAll('img[src^="blob:"]').length`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
|
||||
// Wait for clipboard to be ready
|
||||
await sleep(500);
|
||||
|
||||
@@ -150,8 +156,27 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
|
||||
}, { sessionId });
|
||||
}
|
||||
|
||||
console.log('[x-browser] Waiting for image upload...');
|
||||
await sleep(4000);
|
||||
console.log('[x-browser] Verifying image upload...');
|
||||
const expectedImgCount = imgCountBefore.result.value + 1;
|
||||
let imgUploadOk = false;
|
||||
const imgWaitStart = Date.now();
|
||||
while (Date.now() - imgWaitStart < 15_000) {
|
||||
const r = await cdp!.send<{ result: { value: number } }>('Runtime.evaluate', {
|
||||
expression: `document.querySelectorAll('img[src^="blob:"]').length`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
if (r.result.value >= expectedImgCount) {
|
||||
imgUploadOk = true;
|
||||
break;
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
if (imgUploadOk) {
|
||||
console.log('[x-browser] Image upload verified');
|
||||
} else {
|
||||
console.warn('[x-browser] Image upload not detected after 15s. Run check-paste-permissions.ts to diagnose.');
|
||||
}
|
||||
}
|
||||
|
||||
if (submit) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export type PlatformCandidates = {
|
||||
darwin?: string[];
|
||||
@@ -216,7 +217,7 @@ export class CdpConnection {
|
||||
}
|
||||
|
||||
export function getScriptDir(): string {
|
||||
return path.dirname(new URL(import.meta.url).pathname);
|
||||
return path.dirname(fileURLToPath(import.meta.url));
|
||||
}
|
||||
|
||||
function runBunScript(scriptPath: string, args: string[]): boolean {
|
||||
|
||||
@@ -226,6 +226,7 @@ Schema: `references/config/preferences-schema.md`
|
||||
**1.2 Analyze Content**
|
||||
|
||||
1. Save source content (if pasted, save as `source.md`)
|
||||
- **Backup rule**: If `source.md` exists, rename to `source-backup-YYYYMMDD-HHMMSS.md`
|
||||
2. Follow `references/analysis-framework.md` for content analysis
|
||||
3. Analyze content signals for style recommendations
|
||||
4. Detect source language
|
||||
@@ -483,6 +484,7 @@ options:
|
||||
- Add slide-specific content
|
||||
- If `Layout:` specified, include layout guidance from `references/layouts.md`
|
||||
3. Save to `prompts/` directory
|
||||
- **Backup rule**: If prompt file exists, rename to `prompts/NN-slide-{slug}-backup-YYYYMMDD-HHMMSS.md`
|
||||
|
||||
**After generation**:
|
||||
- If `--prompts-only`, stop here and output prompt summary
|
||||
@@ -539,7 +541,9 @@ options:
|
||||
**Standard flow**:
|
||||
1. Select available image generation skill
|
||||
2. Generate session ID: `slides-{topic-slug}-{timestamp}`
|
||||
3. Generate each slide sequentially with same session ID
|
||||
3. For each slide:
|
||||
- **Backup rule**: If image file exists, rename to `NN-slide-{slug}-backup-YYYYMMDD-HHMMSS.png`
|
||||
- Generate image sequentially with same session ID
|
||||
4. Report progress: "Generated X/N" (in user's language)
|
||||
5. Auto-retry once on failure before reporting error
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: baoyu-xhs-images
|
||||
description: Generates Xiaohongshu (Little Red Book) infographic series with 9 visual styles and 6 layouts. Breaks content into 1-10 cartoon-style images optimized for XHS engagement. Use when user mentions "小红书图片", "XHS images", "RedNote infographics", "小红书种草", or wants social media infographics for Chinese platforms.
|
||||
description: Generates Xiaohongshu (Little Red Book) infographic series with 10 visual styles and 8 layouts. Breaks content into 1-10 cartoon-style images optimized for XHS engagement. Use when user mentions "小红书图片", "XHS images", "RedNote infographics", "小红书种草", or wants social media infographics for Chinese platforms.
|
||||
---
|
||||
|
||||
# Xiaohongshu Infographic Series Generator
|
||||
@@ -42,8 +42,8 @@ Break down complex content into eye-catching infographic series for Xiaohongshu
|
||||
|
||||
| Dimension | Controls | Options |
|
||||
|-----------|----------|---------|
|
||||
| **Style** | Visual aesthetics: colors, lines, decorations | cute, fresh, warm, bold, minimal, retro, pop, notion, chalkboard |
|
||||
| **Layout** | Information structure: density, arrangement | sparse, balanced, dense, list, comparison, flow |
|
||||
| **Style** | Visual aesthetics: colors, lines, decorations | cute, fresh, warm, bold, minimal, retro, pop, notion, chalkboard, study-notes |
|
||||
| **Layout** | Information structure: density, arrangement | sparse, balanced, dense, list, comparison, flow, mindmap, quadrant |
|
||||
|
||||
Style × Layout can be freely combined. Example: `--style notion --layout dense` creates an intellectual-looking knowledge card with high information density.
|
||||
|
||||
@@ -60,6 +60,7 @@ Style × Layout can be freely combined. Example: `--style notion --layout dense`
|
||||
| `pop` | Vibrant, energetic, eye-catching |
|
||||
| `notion` | Minimalist hand-drawn line art, intellectual |
|
||||
| `chalkboard` | Colorful chalk on black board, educational |
|
||||
| `study-notes` | Realistic handwritten photo style, blue pen + red annotations + yellow highlighter |
|
||||
|
||||
Detailed style definitions: `references/presets/<style>.md`
|
||||
|
||||
@@ -73,6 +74,8 @@ Detailed style definitions: `references/presets/<style>.md`
|
||||
| `list` | Enumeration and ranking format (4-7 items) |
|
||||
| `comparison` | Side-by-side contrast layout |
|
||||
| `flow` | Process and timeline layout (3-6 steps) |
|
||||
| `mindmap` | Center radial mind map layout (4-8 branches) |
|
||||
| `quadrant` | Four-quadrant / circular section layout |
|
||||
|
||||
Detailed layout definitions: `references/elements/canvas.md`
|
||||
|
||||
@@ -89,6 +92,7 @@ Detailed layout definitions: `references/elements/canvas.md`
|
||||
| Fun, exciting, wow, amazing | `pop` | sparse/list |
|
||||
| Knowledge, concept, productivity, SaaS | `notion` | dense/list |
|
||||
| Education, tutorial, learning, teaching, classroom | `chalkboard` | balanced/dense |
|
||||
| Notes, handwritten, study guide, knowledge, realistic, photo | `study-notes` | dense/list/mindmap |
|
||||
|
||||
## Outline Strategies
|
||||
|
||||
@@ -164,7 +168,9 @@ Copy and track progress:
|
||||
|
||||
```
|
||||
XHS Infographic Progress:
|
||||
- [ ] Step 0: Check preferences (EXTEND.md) ⚠️ REQUIRED if not found
|
||||
- [ ] Step 0: Check preferences (EXTEND.md) ⛔ BLOCKING
|
||||
- [ ] Found → load preferences → continue
|
||||
- [ ] Not found → run first-time setup → MUST complete before Step 1
|
||||
- [ ] Step 1: Analyze content → analysis.md
|
||||
- [ ] Step 2: Confirmation 1 - Content understanding ⚠️ REQUIRED
|
||||
- [ ] Step 3: Generate 3 outline + style variants
|
||||
@@ -176,12 +182,22 @@ XHS Infographic Progress:
|
||||
### Flow
|
||||
|
||||
```
|
||||
Input → Analyze → [Confirm 1] → 3 Outlines → [Confirm 2: Outline + Style + Elements] → Generate → Complete
|
||||
Input → [Step 0: Preferences] ─┬─ Found → Continue
|
||||
│
|
||||
└─ Not found → First-Time Setup ⛔ BLOCKING
|
||||
│
|
||||
└─ Complete setup → Save EXTEND.md → Continue
|
||||
│
|
||||
┌───────────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
Analyze → [Confirm 1] → 3 Outlines → [Confirm 2: Outline + Style + Elements] → Generate → Complete
|
||||
```
|
||||
|
||||
### Step 0: Load Preferences (EXTEND.md) ⚠️
|
||||
### Step 0: Load Preferences (EXTEND.md) ⛔ BLOCKING
|
||||
|
||||
**Purpose**: Load user preferences or run first-time setup. **Do NOT skip setup if EXTEND.md not found.**
|
||||
**Purpose**: Load user preferences or run first-time setup.
|
||||
|
||||
**CRITICAL**: If EXTEND.md not found, MUST complete first-time setup before ANY other questions or steps. Do NOT proceed to content analysis, do NOT ask about style, do NOT ask about layout — ONLY complete the preferences setup first.
|
||||
|
||||
Use Bash to check EXTEND.md existence (priority order):
|
||||
|
||||
@@ -201,13 +217,13 @@ test -f "$HOME/.baoyu-skills/baoyu-xhs-images/EXTEND.md" && echo "user"
|
||||
│ $HOME/.baoyu-skills/baoyu-xhs-images/EXTEND.md │ User home │
|
||||
└────────────────────────────────────────────────────┴───────────────────┘
|
||||
|
||||
┌───────────┬───────────────────────────────────────────────────────────────────────────┐
|
||||
│ Result │ Action │
|
||||
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||
│ Found │ Read, parse, display summary → Continue to Step 1 │
|
||||
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||
│ Not found │ ⚠️ MUST run first-time setup (see below) → Then continue to Step 1 │
|
||||
└───────────┴───────────────────────────────────────────────────────────────────────────┘
|
||||
┌───────────┬─────────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Result │ Action │
|
||||
├───────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Found │ Read, parse, display summary → Continue to Step 1 │
|
||||
├───────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Not found │ ⛔ BLOCKING: Run first-time setup ONLY (see below) → Complete and save EXTEND.md → Then Step 1 │
|
||||
└───────────┴─────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
**First-Time Setup** (when EXTEND.md not found):
|
||||
|
||||
@@ -227,6 +243,7 @@ Read source content, save it if needed, and perform deep analysis.
|
||||
1. **Save source content** (if not already a file):
|
||||
- If user provides a file path: use as-is
|
||||
- If user pastes content: save to `source.md` in target directory
|
||||
- **Backup rule**: If `source.md` exists, rename to `source-backup-YYYYMMDD-HHMMSS.md`
|
||||
2. Read source content
|
||||
3. **Deep analysis** following `references/workflows/analysis-framework.md`:
|
||||
- Content type classification (种草/干货/测评/教程/避坑...)
|
||||
@@ -351,9 +368,22 @@ Display the selected style's default elements from preset, then ask:
|
||||
|
||||
With confirmed outline + style + layout:
|
||||
|
||||
**Visual Consistency — Reference Image Chain**:
|
||||
To ensure character/style consistency across all images in a series:
|
||||
1. **Generate image 1 (cover) FIRST** — without `--ref`
|
||||
2. **Use image 1 as `--ref` for ALL remaining images** (2, 3, ..., N)
|
||||
- This anchors the character design, color rendering, and illustration style
|
||||
- Command pattern: `--ref <path-to-image-01.png>` added to every subsequent generation
|
||||
|
||||
This is critical for styles that use recurring characters, mascots, or illustration elements. Image 1 becomes the visual anchor for the entire series.
|
||||
|
||||
**For each image (cover + content + ending)**:
|
||||
1. Save prompt to `prompts/NN-{type}-[slug].md` (in user's preferred language)
|
||||
2. Generate image using confirmed style and layout
|
||||
- **Backup rule**: If prompt file exists, rename to `prompts/NN-{type}-[slug]-backup-YYYYMMDD-HHMMSS.md`
|
||||
2. Generate image:
|
||||
- **Image 1**: Generate without `--ref` (this establishes the visual anchor)
|
||||
- **Images 2+**: Generate with `--ref <image-01-path>` for consistency
|
||||
- **Backup rule**: If image file exists, rename to `NN-{type}-[slug]-backup-YYYYMMDD-HHMMSS.png`
|
||||
3. Report progress after each generation
|
||||
|
||||
**Watermark Application** (if enabled in preferences):
|
||||
@@ -372,7 +402,7 @@ Reference: `references/config/watermark-guide.md`
|
||||
If image generation skill supports `--sessionId`:
|
||||
1. Generate unique session ID: `xhs-{topic-slug}-{timestamp}`
|
||||
2. Use same session ID for all images
|
||||
3. Ensures visual consistency across generated images
|
||||
3. Combined with reference image chain, ensures maximum visual consistency
|
||||
|
||||
### Step 6: Completion Report
|
||||
|
||||
@@ -417,17 +447,18 @@ Files:
|
||||
|
||||
**Style × Layout Matrix** (✓✓ = highly recommended, ✓ = works well):
|
||||
|
||||
| | sparse | balanced | dense | list | comparison | flow |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| cute | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ |
|
||||
| fresh | ✓✓ | ✓✓ | ✓ | ✓ | ✓ | ✓✓ |
|
||||
| warm | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓ |
|
||||
| bold | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
| minimal | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓ |
|
||||
| retro | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ |
|
||||
| pop | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
| notion | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ |
|
||||
| chalkboard | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓✓ |
|
||||
| | sparse | balanced | dense | list | comparison | flow | mindmap | quadrant |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| cute | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| fresh | ✓✓ | ✓✓ | ✓ | ✓ | ✓ | ✓✓ | ✓ | ✓ |
|
||||
| warm | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓ | ✓ | ✓ |
|
||||
| bold | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ |
|
||||
| minimal | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| retro | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| pop | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓ |
|
||||
| notion | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ |
|
||||
| chalkboard | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
| study-notes | ✗ | ✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓ |
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -9,6 +9,14 @@ description: First-time setup flow for baoyu-xhs-images preferences
|
||||
|
||||
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/article
|
||||
- Ask about style or layout
|
||||
- Ask about target audience
|
||||
- Proceed to content analysis
|
||||
|
||||
ONLY ask the questions in this setup flow, save EXTEND.md, then continue.
|
||||
|
||||
## Setup Flow
|
||||
|
||||
```
|
||||
|
||||
@@ -51,6 +51,8 @@ Avoid placing critical content in these areas:
|
||||
| list | Vertical enumeration | 4-7 | Rankings, checklists, step guides |
|
||||
| comparison | Left vs Right | 2 sections | Before/after, pros/cons |
|
||||
| flow | Connected nodes | 3-6 steps | Processes, timelines, workflows |
|
||||
| mindmap | Center radial | 4-8 branches | Concept maps, brainstorming, topic overview |
|
||||
| quadrant | 4-section grid | 4 sections | SWOT analysis, priority matrix, classification |
|
||||
|
||||
## Layout by Position
|
||||
|
||||
@@ -106,3 +108,15 @@ For multi-element compositions:
|
||||
- Directional flow (top→bottom or left→right)
|
||||
- Connected nodes with arrows
|
||||
- Clear progression indicators
|
||||
|
||||
### Mindmap Layout
|
||||
- Central topic node
|
||||
- Radial branches outward
|
||||
- Hierarchical sub-branches
|
||||
- Organic curved connections
|
||||
|
||||
### Quadrant Layout
|
||||
- 4-section grid (2×2)
|
||||
- Clear axis labels
|
||||
- Each quadrant with distinct content
|
||||
- Optional circular variant for cycles
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
name: study-notes
|
||||
category: realistic
|
||||
---
|
||||
|
||||
# Study Notes Style
|
||||
|
||||
Realistic handwritten photo aesthetic - student notes style, dense and messy but readable.
|
||||
|
||||
## Element Combination
|
||||
|
||||
```yaml
|
||||
canvas:
|
||||
ratio: portrait-3-4
|
||||
grid: single
|
||||
|
||||
image_effects:
|
||||
cutout: none
|
||||
stroke: none
|
||||
filter: natural-photo
|
||||
|
||||
typography:
|
||||
decorated: none
|
||||
tags: none
|
||||
direction: horizontal
|
||||
|
||||
decorations:
|
||||
emphasis: circle-mark | underline | checkmark | cross | star-simple
|
||||
background: lined-paper-white
|
||||
doodles: arrows-simple | margin-notes | corrections | explanatory-diagrams
|
||||
frames: none
|
||||
```
|
||||
|
||||
## Color Palette (Three-Color Annotation System)
|
||||
|
||||
| Role | Colors | Hex |
|
||||
|------|--------|-----|
|
||||
| Primary | Blue ballpoint, Black ink | #1E3A5F, #1A1A1A |
|
||||
| Highlights | Yellow highlighter | #FFFF00 (50% opacity) |
|
||||
| Accents | Red pen (circles, underlines) | #CC0000 |
|
||||
| Background | White lined paper | #FFFFFF |
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Realistic photo perspective: top-down view of study desk
|
||||
- Hand holding blue ballpoint pen, actively underlining
|
||||
- Extremely dense handwritten content, filling entire page
|
||||
- Red pen annotations: circles, underlines, stars, boxes
|
||||
- Yellow highlighter marking key terms
|
||||
- Correction marks, cramped notes squeezed into margins
|
||||
- Simple hand-drawn symbols: → * ✓ ✗ !
|
||||
- Varying pen pressure creating lighter and darker strokes
|
||||
|
||||
## Typography
|
||||
|
||||
- Authentic student handwriting
|
||||
- Messy but readable, clear structure maintained
|
||||
- Varying font sizes (large titles, small body, tiny margin notes)
|
||||
- CJK optimized
|
||||
|
||||
## Content Structure
|
||||
|
||||
Three-section layout:
|
||||
|
||||
### Top Section
|
||||
- Core topic (circled multiple times in red)
|
||||
- First section title + 3-4 key points
|
||||
- Arrow connections, red underlines
|
||||
|
||||
### Middle Section
|
||||
- Second section title (red pen box)
|
||||
- Numbered steps ①②③
|
||||
- Specific methods and supplementary notes
|
||||
|
||||
### Bottom Section
|
||||
- Third section title (red star)
|
||||
- Time points / key metrics
|
||||
- Key quotes / core tips (tiny corner notes)
|
||||
|
||||
## Best Layout Pairings
|
||||
|
||||
| Layout | Compatibility | Use Case |
|
||||
|--------|---------------|----------|
|
||||
| sparse | ✗ | Not suitable - style requires dense content |
|
||||
| balanced | ✓ | When content is lighter |
|
||||
| dense | ✓✓ | Best fit - knowledge notes, summaries |
|
||||
| list | ✓✓ | Step checklists, rankings |
|
||||
| comparison | ✓ | Comparative analysis |
|
||||
| flow | ✓ | Process flows |
|
||||
| mindmap | ✓✓ | Mind map notes |
|
||||
| quadrant | ✓ | Quadrant analysis |
|
||||
|
||||
## Best For
|
||||
|
||||
- Study guides, exam notes
|
||||
- Knowledge organization, framework summaries
|
||||
- Tutorial summaries, quick notes
|
||||
- "Top student notes" style content
|
||||
- Knowledge sharing requiring authentic feel
|
||||
|
||||
## Style Rules
|
||||
|
||||
### DO ✓
|
||||
- Keep content extremely dense
|
||||
- Use simple symbols (→ * ✓ ✗ !)
|
||||
- Annotate key points with red pen
|
||||
- Include correction marks
|
||||
- Squeeze tiny notes into margins
|
||||
|
||||
### DON'T ✗
|
||||
- Use complex emojis
|
||||
- Leave too much whitespace
|
||||
- Make neat, tidy layouts
|
||||
- Add colorful decorations
|
||||
- Include cartoon elements
|
||||
@@ -160,7 +160,21 @@ From outline entry, format:
|
||||
If preferences include watermark:
|
||||
- Add watermark section with content, position, opacity
|
||||
|
||||
### Step 5: Combine
|
||||
### Step 5: Visual Consistency — Reference Image Chain
|
||||
|
||||
When generating multiple images in a series:
|
||||
|
||||
1. **Image 1 (cover)**: Generate without `--ref` — this establishes the visual anchor
|
||||
2. **Images 2+**: Always pass image 1 as `--ref` to the image generation skill:
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts \
|
||||
--promptfiles prompts/02-content-xxx.md \
|
||||
--ref path/to/01-cover-xxx.png \
|
||||
--image 02-content-xxx.png --ar 3:4 --quality 2k
|
||||
```
|
||||
This ensures the AI maintains the same character design, illustration style, and color rendering across the series.
|
||||
|
||||
### Step 6: Combine
|
||||
|
||||
Assemble all sections into final prompt following base structure.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user