mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 05:51:44 +08:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 156f8627c2 | |||
| e0b861c148 | |||
| 3b29f3c57c | |||
| f8fb457f36 | |||
| 174e472a39 | |||
| 8b99fa7af0 | |||
| 6699b802c0 | |||
| edcdc19ac5 | |||
| 5d2a39c636 | |||
| db58bdee8c | |||
| 38cc497748 | |||
| 0f95d12a09 | |||
| 7fd9e51fc2 | |||
| a5d227b4e8 | |||
| 81377416b4 | |||
| a44bb08360 | |||
| a07669136c | |||
| b7298a60c6 | |||
| 309f078efb | |||
| 8958ba5409 | |||
| e6612628dc | |||
| bd5745f837 | |||
| cb26732559 | |||
| 9baf570caa | |||
| f99815cec9 | |||
| 8f0663d515 | |||
| 20ebf6126c | |||
| 64db328e61 | |||
| a3819b8e30 | |||
| 234c2a832b | |||
| 7943eb7b05 | |||
| 4b4a4d3863 |
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||||
"version": "1.116.0"
|
"version": "1.117.3"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -18,8 +20,16 @@ jobs:
|
|||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
|
- name: Verify skill release commits
|
||||||
|
run: npm run verify:skill-release-commits
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install baoyu-post-to-wechat script dependencies
|
||||||
|
run: |
|
||||||
|
cd skills/baoyu-post-to-wechat/scripts
|
||||||
|
npm install --no-audit --no-fund --ignore-scripts
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|||||||
@@ -2,6 +2,67 @@
|
|||||||
|
|
||||||
English | [中文](./CHANGELOG.zh.md)
|
English | [中文](./CHANGELOG.zh.md)
|
||||||
|
|
||||||
|
## 1.117.3 - 2026-05-20
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- CI: add skill release commit validation — commits touching `skills/<name>/**` must use Conventional Commit subjects; SKILL.md version validated during publish/sync
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-diagram`: add version field to SKILL.md
|
||||||
|
- `baoyu-post-to-wechat`: sync SKILL.md version
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- `baoyu-wechat-summary`: restructure profile fields — split `aliases` into `group_nicknames` (user's own prior names) and `aliases` (nicknames from other members), add `tags` for cross-cutting attributes
|
||||||
|
|
||||||
|
## 1.117.2 - 2026-05-17
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- `baoyu-cover-image`: ban programmatic text repair on generated bitmaps — disallow ImageMagick / Pillow / Canvas / SVG / HTML overlays to cover, rewrite, or replace title/subtitle text; regenerate from a corrected prompt or switch to a lower-text or no-title variant instead
|
||||||
|
- `baoyu-article-illustrator`, `baoyu-comic`, `baoyu-image-cards`, `baoyu-xhs-images`, `baoyu-infographic`, `baoyu-slide-deck`: sync the same text-repair ban with skill-specific text categories (labels/captions, dialogue/sound effects, titles/body/tags, headings/data values, slide titles/bullets)
|
||||||
|
|
||||||
|
## 1.117.1 - 2026-05-16
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-post-to-wechat`: fix WeChat browser article publishing (by @zhangga)
|
||||||
|
- `baoyu-post-to-wechat`: fix image upload fallback and WebP clipboard copy on macOS
|
||||||
|
|
||||||
|
## 1.117.0 - 2026-05-16
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-article-illustrator`: add batch generation policy — backend native batch first, runtime parallel calls second, sequential fallback; configurable `generation_batch_size` and `--batch-size` option
|
||||||
|
- `baoyu-comic`: add batch generation policy with dependency-aware ordering (character sheet before pages) and configurable `--batch-size`
|
||||||
|
- `baoyu-image-cards`: add batch generation policy honoring image-1 anchor chain, with configurable `--batch-size`
|
||||||
|
- `baoyu-slide-deck`: add batch generation policy for slide image rendering with configurable `--batch-size`
|
||||||
|
- `baoyu-xhs-images`: sync batch generation policy from baoyu-image-cards
|
||||||
|
|
||||||
|
## 1.116.5 - 2026-05-14
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-post-to-wechat`: send WeChat login QR code to Telegram when `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` env vars are set, enabling headless / remote login flows (by @beforesun)
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
- `baoyu-post-to-wechat`: harden Telegram QR notification — add 10s fetch timeout, defer the 2s QR-render wait until env vars are configured, and use viewport screenshot as fallback
|
||||||
|
|
||||||
|
## 1.116.4 - 2026-05-14
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
- `baoyu-wechat-summary`: streamline roast version prompts in output-formats.md (99 → 23 lines), add roast-specific profile usage bullets to SKILL.md Round 2
|
||||||
|
|
||||||
|
## 1.116.3 - 2026-05-13
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Replace Claude Code references with generic Agent wording in READMEs to reflect multi-agent support (Claude Code, Codex, etc.)
|
||||||
|
|
||||||
|
## 1.116.2 - 2026-05-13
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- `baoyu-wechat-summary`: update example group name in SKILL.md
|
||||||
|
|
||||||
|
## 1.116.1 - 2026-05-13
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-wechat-summary`: add `data_root` option to first-time setup flow, allowing users to customize the digest output directory during initialization
|
||||||
|
|
||||||
## 1.116.0 - 2026-05-13
|
## 1.116.0 - 2026-05-13
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -2,6 +2,67 @@
|
|||||||
|
|
||||||
[English](./CHANGELOG.md) | 中文
|
[English](./CHANGELOG.md) | 中文
|
||||||
|
|
||||||
|
## 1.117.3 - 2026-05-20
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- CI:新增 skill 发布提交校验 —— 涉及 `skills/<name>/**` 的提交必须使用 Conventional Commit 格式;发布/同步时校验 SKILL.md 版本一致性
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-diagram`:为 SKILL.md 添加 version 字段
|
||||||
|
- `baoyu-post-to-wechat`:同步 SKILL.md 版本
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- `baoyu-wechat-summary`:重构 profile 字段 —— 将 `aliases` 拆分为 `group_nicknames`(用户历史群名)和 `aliases`(其他成员对用户的称呼),新增 `tags` 字段存储横向属性
|
||||||
|
|
||||||
|
## 1.117.2 - 2026-05-17
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- `baoyu-cover-image`:禁止用代码修补已生成的位图文字 —— 不再使用 ImageMagick / Pillow / Canvas / SVG / HTML 叠层覆盖、重写或替换标题/副标题文字,文字异常时应改 prompt 重新生成或换用少字/无标题版本
|
||||||
|
- `baoyu-article-illustrator`、`baoyu-comic`、`baoyu-image-cards`、`baoyu-xhs-images`、`baoyu-infographic`、`baoyu-slide-deck`:同步上述文字修补禁令,各自针对该 skill 的文字类别(标签/说明、对白/拟声词、标题/正文/标签、标题/数据、幻灯片标题/要点)
|
||||||
|
|
||||||
|
## 1.117.1 - 2026-05-16
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-post-to-wechat`:修复微信浏览器文章发布问题 (by @zhangga)
|
||||||
|
- `baoyu-post-to-wechat`:修复图片上传回退逻辑及 macOS WebP 剪贴板复制
|
||||||
|
|
||||||
|
## 1.117.0 - 2026-05-16
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-article-illustrator`:新增批量生成策略 —— 优先使用后端原生批量接口,其次运行时并行调用,最后顺序生成;支持 `generation_batch_size` 配置和 `--batch-size` 参数
|
||||||
|
- `baoyu-comic`:新增批量生成策略,支持依赖感知排序(角色图先于页面)和 `--batch-size` 参数
|
||||||
|
- `baoyu-image-cards`:新增批量生成策略,遵循 image-1 锚定链,支持 `--batch-size` 参数
|
||||||
|
- `baoyu-slide-deck`:新增幻灯片图片批量生成策略,支持 `--batch-size` 参数
|
||||||
|
- `baoyu-xhs-images`:同步 baoyu-image-cards 的批量生成策略
|
||||||
|
|
||||||
|
## 1.116.5 - 2026-05-14
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-post-to-wechat`:当设置 `TELEGRAM_BOT_TOKEN` 和 `TELEGRAM_CHAT_ID` 环境变量时,自动将微信登录二维码发送到 Telegram,支持无显示器/远程登录场景 (by @beforesun)
|
||||||
|
|
||||||
|
### 重构
|
||||||
|
- `baoyu-post-to-wechat`:加固 Telegram QR 通知逻辑 —— 增加 10 秒 fetch 超时、未配置环境变量时不再无谓等待 2 秒、回退截图改为视口范围以减小体积
|
||||||
|
|
||||||
|
## 1.116.4 - 2026-05-14
|
||||||
|
|
||||||
|
### 重构
|
||||||
|
- `baoyu-wechat-summary`:精简毒舌版提示词(99 → 23 行),在 SKILL.md Round 2 中增加 roast 专用的画像使用指引
|
||||||
|
|
||||||
|
## 1.116.3 - 2026-05-13
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- README 中将 Claude Code 替换为通用的 Agent 表述,体现多 Agent 支持(Claude Code、Codex 等)
|
||||||
|
|
||||||
|
## 1.116.2 - 2026-05-13
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- `baoyu-wechat-summary`:更新 SKILL.md 中的示例群名
|
||||||
|
|
||||||
|
## 1.116.1 - 2026-05-13
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-wechat-summary`:初始化设置流程中新增 `data_root` 选项,允许用户在首次配置时自定义摘要输出目录
|
||||||
|
|
||||||
## 1.116.0 - 2026-05-13
|
## 1.116.0 - 2026-05-13
|
||||||
|
|
||||||
### 新功能
|
### 新功能
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
English | [中文](./README.zh.md)
|
English | [中文](./README.zh.md)
|
||||||
|
|
||||||
Skills shared by Baoyu for improving daily work efficiency with Claude Code.
|
Skills shared by Baoyu for improving daily work efficiency with AI Agents (Claude Code, Codex, etc.).
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Publishing to ClawHub releases the published skill under `MIT-0`, per ClawHub's
|
|||||||
|
|
||||||
### Register as Plugin Marketplace
|
### Register as Plugin Marketplace
|
||||||
|
|
||||||
Run the following command in Claude Code:
|
Run the following command in the Agent:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/plugin marketplace add JimLiu/baoyu-skills
|
/plugin marketplace add JimLiu/baoyu-skills
|
||||||
@@ -64,7 +64,7 @@ Run the following command in Claude Code:
|
|||||||
|
|
||||||
**Option 3: Ask the Agent**
|
**Option 3: Ask the Agent**
|
||||||
|
|
||||||
Simply tell Claude Code:
|
Simply tell the Agent:
|
||||||
|
|
||||||
> Please install Skills from github.com/JimLiu/baoyu-skills
|
> Please install Skills from github.com/JimLiu/baoyu-skills
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ The marketplace now exposes a single plugin so each skill is registered exactly
|
|||||||
|
|
||||||
To update skills to the latest version:
|
To update skills to the latest version:
|
||||||
|
|
||||||
1. Run `/plugin` in Claude Code
|
1. Run `/plugin` in the Agent
|
||||||
2. Switch to **Marketplaces** tab (use arrow keys or Tab)
|
2. Switch to **Marketplaces** tab (use arrow keys or Tab)
|
||||||
3. Select **baoyu-skills**
|
3. Select **baoyu-skills**
|
||||||
4. Choose **Update marketplace**
|
4. Choose **Update marketplace**
|
||||||
@@ -579,7 +579,7 @@ Plain text input is treated as a regular post. Markdown files are treated as X A
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Post with text
|
# Post with text
|
||||||
/baoyu-post-to-x "Hello from Claude Code!"
|
/baoyu-post-to-x "Hello from AI Agent!"
|
||||||
|
|
||||||
# Post with images
|
# Post with images
|
||||||
/baoyu-post-to-x "Check this out" --image photo.png
|
/baoyu-post-to-x "Check this out" --image photo.png
|
||||||
@@ -612,8 +612,9 @@ Post content to WeChat Official Account (微信公众号). Two modes available:
|
|||||||
|
|
||||||
| Method | Speed | Requirements |
|
| Method | Speed | Requirements |
|
||||||
|--------|-------|--------------|
|
|--------|-------|--------------|
|
||||||
| API (Recommended) | Fast | API credentials |
|
| API (Recommended) | Fast | API credentials (local IP allowlisted in WeChat) |
|
||||||
| Browser | Slow | Chrome, login session |
|
| Browser | Slow | Chrome, login session |
|
||||||
|
| Remote API | Fast | API credentials + SSH-reachable server whose IP is on WeChat's allowlist |
|
||||||
|
|
||||||
**API Configuration** (for faster publishing):
|
**API Configuration** (for faster publishing):
|
||||||
|
|
||||||
@@ -631,6 +632,17 @@ To obtain credentials:
|
|||||||
|
|
||||||
**Browser Method** (no API setup needed): Requires Google Chrome. First run opens browser for QR code login (session preserved).
|
**Browser Method** (no API setup needed): Requires Google Chrome. First run opens browser for QR code login (session preserved).
|
||||||
|
|
||||||
|
**Remote API Method** (for when WeChat's IP allowlist excludes your local machine): tunnels WeChat API calls through an SSH SOCKS5 dynamic port forward to a server whose IP is on the allowlist. No files are written to the remote host and `AppSecret` never leaves the local process. Add to your EXTEND.md:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Optional: only set when WeChat's IP allowlist excludes your local machine
|
||||||
|
remote_publish_host: server.example.com
|
||||||
|
remote_publish_user: deploy
|
||||||
|
remote_publish_identity_file: ~/.ssh/id_ed25519
|
||||||
|
```
|
||||||
|
|
||||||
|
Then publish with `--remote` (or set `default_publish_method: remote-api`). Authentication is SSH key only; only the typed `remote_publish_*` keys are honored.
|
||||||
|
|
||||||
**Multi-Account Support**: Manage multiple WeChat Official Accounts via `EXTEND.md`:
|
**Multi-Account Support**: Manage multiple WeChat Official Accounts via `EXTEND.md`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -1118,16 +1130,16 @@ Summarize WeChat group chat highlights into a structured digest. Extracts topics
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Summarize a group's recent messages
|
# Summarize a group's recent messages
|
||||||
/baoyu-wechat-summary 消失的大叔 最近 1 天
|
/baoyu-wechat-summary 相亲相爱一家人 最近 1 天
|
||||||
|
|
||||||
# Weekly summary
|
# Weekly summary
|
||||||
/baoyu-wechat-summary AI 技术群 最近 7 天
|
/baoyu-wechat-summary AI 技术群 最近 7 天
|
||||||
|
|
||||||
# Incremental (since last digest)
|
# Incremental (since last digest)
|
||||||
/baoyu-wechat-summary 消失的大叔
|
/baoyu-wechat-summary 相亲相爱一家人
|
||||||
|
|
||||||
# Roast version
|
# Roast version
|
||||||
/baoyu-wechat-summary 消失的大叔 最近 3 天 毒舌版
|
/baoyu-wechat-summary 相亲相爱一家人 最近 3 天 毒舌版
|
||||||
```
|
```
|
||||||
|
|
||||||
**Requirements**:
|
**Requirements**:
|
||||||
|
|||||||
+12
-9
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
[English](./README.md) | 中文
|
[English](./README.md) | 中文
|
||||||
|
|
||||||
宝玉分享的 Claude Code 技能集,提升日常工作效率。
|
宝玉分享的 AI Agent 技能集(适用于 Claude Code、Codex 等),提升日常工作效率。
|
||||||
|
|
||||||
## 前置要求
|
## 前置要求
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ clawhub install baoyu-markdown-to-html
|
|||||||
|
|
||||||
### 注册插件市场
|
### 注册插件市场
|
||||||
|
|
||||||
在 Claude Code 中运行:
|
在 Agent 中运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/plugin marketplace add JimLiu/baoyu-skills
|
/plugin marketplace add JimLiu/baoyu-skills
|
||||||
@@ -64,7 +64,7 @@ clawhub install baoyu-markdown-to-html
|
|||||||
|
|
||||||
**方式三:告诉 Agent**
|
**方式三:告诉 Agent**
|
||||||
|
|
||||||
直接告诉 Claude Code:
|
直接告诉 Agent:
|
||||||
|
|
||||||
> 请帮我安装 github.com/JimLiu/baoyu-skills 中的 Skills
|
> 请帮我安装 github.com/JimLiu/baoyu-skills 中的 Skills
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ clawhub install baoyu-markdown-to-html
|
|||||||
|
|
||||||
更新技能到最新版本:
|
更新技能到最新版本:
|
||||||
|
|
||||||
1. 在 Claude Code 中运行 `/plugin`
|
1. 在 Agent 中运行 `/plugin`
|
||||||
2. 切换到 **Marketplaces** 标签页(使用方向键或 Tab)
|
2. 切换到 **Marketplaces** 标签页(使用方向键或 Tab)
|
||||||
3. 选择 **baoyu-skills**
|
3. 选择 **baoyu-skills**
|
||||||
4. 选择 **Update marketplace**
|
4. 选择 **Update marketplace**
|
||||||
@@ -579,7 +579,7 @@ clawhub install baoyu-markdown-to-html
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 发布文字
|
# 发布文字
|
||||||
/baoyu-post-to-x "Hello from Claude Code!"
|
/baoyu-post-to-x "Hello from AI Agent!"
|
||||||
|
|
||||||
# 发布带图片
|
# 发布带图片
|
||||||
/baoyu-post-to-x "看看这个" --image photo.png
|
/baoyu-post-to-x "看看这个" --image photo.png
|
||||||
@@ -612,8 +612,9 @@ clawhub install baoyu-markdown-to-html
|
|||||||
|
|
||||||
| 方式 | 速度 | 要求 |
|
| 方式 | 速度 | 要求 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| API(推荐) | 快 | API 凭证 |
|
| API(推荐) | 快 | API 凭证(本机 IP 在公众号白名单内) |
|
||||||
| 浏览器 | 慢 | Chrome,登录会话 |
|
| 浏览器 | 慢 | Chrome,登录会话 |
|
||||||
|
| 远程 API | 快 | API 凭证 + 一台 IP 在公众号白名单内、可 SSH 登录的服务器 |
|
||||||
|
|
||||||
**API 配置**(更快的发布方式):
|
**API 配置**(更快的发布方式):
|
||||||
|
|
||||||
@@ -631,6 +632,8 @@ WECHAT_APP_SECRET=你的AppSecret
|
|||||||
|
|
||||||
**浏览器方式**(无需 API 配置):需已安装 Google Chrome,首次运行需扫码登录(登录状态会保存)
|
**浏览器方式**(无需 API 配置):需已安装 Google Chrome,首次运行需扫码登录(登录状态会保存)
|
||||||
|
|
||||||
|
**远程 API 方式**(适用于本机 IP 不在公众号白名单内的情况):通过 SSH SOCKS5 动态端口转发,将对 `api.weixin.qq.com` 的 HTTPS 调用转发到 IP 在白名单内的服务器上。Markdown 渲染、图片处理、草稿组装仍在本地完成;远端不会落任何文件,`AppSecret` 不会离开本地进程。仅支持 SSH 密钥认证,且只接受类型化的 `remote_publish_*` 配置项,不透传任意 ssh 选项。在 EXTEND.md 中配置 `remote_publish_host` 等字段后,发布时加上 `--remote`(或将 `default_publish_method` 设为 `remote-api`)。
|
||||||
|
|
||||||
**多账号支持**:通过 `EXTEND.md` 管理多个微信公众号:
|
**多账号支持**:通过 `EXTEND.md` 管理多个微信公众号:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -1118,16 +1121,16 @@ AI 驱动的生成后端。
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 总结群最近消息
|
# 总结群最近消息
|
||||||
/baoyu-wechat-summary 消失的大叔 最近 1 天
|
/baoyu-wechat-summary 相亲相爱一家人 最近 1 天
|
||||||
|
|
||||||
# 周报
|
# 周报
|
||||||
/baoyu-wechat-summary AI 技术群 最近 7 天
|
/baoyu-wechat-summary AI 技术群 最近 7 天
|
||||||
|
|
||||||
# 增量模式(从上次摘要继续)
|
# 增量模式(从上次摘要继续)
|
||||||
/baoyu-wechat-summary 消失的大叔
|
/baoyu-wechat-summary 相亲相爱一家人
|
||||||
|
|
||||||
# 毒舌版
|
# 毒舌版
|
||||||
/baoyu-wechat-summary 消失的大叔 最近 3 天 毒舌版
|
/baoyu-wechat-summary 相亲相爱一家人 最近 3 天 毒舌版
|
||||||
```
|
```
|
||||||
|
|
||||||
**前置要求**:
|
**前置要求**:
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ bash scripts/sync-clawhub.sh <skill> # sync one skill
|
|||||||
|
|
||||||
Release hooks are configured via `.releaserc.yml`. This repo does not stage a separate release directory: publish reads the skill directory directly and validates that local package references and CLI bin targets are self-contained.
|
Release hooks are configured via `.releaserc.yml`. This repo does not stage a separate release directory: publish reads the skill directory directly and validates that local package references and CLI bin targets are self-contained.
|
||||||
|
|
||||||
|
Every skill release must keep the `version:` in that skill's `SKILL.md` aligned with the version being published. `publish-skill.mjs` and `sync-clawhub.mjs` both reject mismatches so a registry payload cannot ship with stale skill metadata.
|
||||||
|
|
||||||
|
Commits that touch `skills/<name>/**` must use Conventional Commit subjects, for example `fix(baoyu-post-to-wechat): handle WeChat editor focus`. CI runs `npm run verify:skill-release-commits` against the pushed or PR commit range so bare subjects like `Fix WeChat browser article publishing` cannot bypass per-skill release versioning silently.
|
||||||
|
|
||||||
## Shared Workspace Packages
|
## Shared Workspace Packages
|
||||||
|
|
||||||
`packages/` is the source of truth for shared runtime code. Most skills consume shared packages from npm with semver ranges. `baoyu-url-to-markdown` is the exception: it vendors the `baoyu-fetch` runtime into `skills/baoyu-url-to-markdown/scripts/lib/` so the published skill is self-contained and does not depend on the `baoyu-fetch` npm package.
|
`packages/` is the source of truth for shared runtime code. Most skills consume shared packages from npm with semver ranges. `baoyu-url-to-markdown` is the exception: it vendors the `baoyu-fetch` runtime into `skills/baoyu-url-to-markdown/scripts/lib/` so the published skill is self-contained and does not depend on the `baoyu-fetch` npm package.
|
||||||
|
|||||||
+2
-1
@@ -7,7 +7,8 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node ./scripts/run-node-tests.mjs",
|
"test": "node ./scripts/run-node-tests.mjs",
|
||||||
"test:coverage": "node ./scripts/run-node-tests.mjs --experimental-test-coverage"
|
"test:coverage": "node ./scripts/run-node-tests.mjs --experimental-test-coverage",
|
||||||
|
"verify:skill-release-commits": "node ./scripts/verify-skill-release-commits.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
|
|||||||
@@ -25,6 +25,37 @@ const MIME_MAP = {
|
|||||||
".svg": "image/svg+xml",
|
".svg": "image/svg+xml",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function readSkillMetadataVersion(root) {
|
||||||
|
const skillFile = await findSkillMarkdown(root);
|
||||||
|
const source = await fs.readFile(skillFile, "utf8");
|
||||||
|
const version = readSkillFrontmatterVersion(source);
|
||||||
|
if (!version) {
|
||||||
|
throw new Error(`Missing version in ${path.relative(process.cwd(), skillFile) || skillFile}`);
|
||||||
|
}
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateSkillMetadataVersion(root, expectedVersion) {
|
||||||
|
const actualVersion = await readSkillMetadataVersion(root);
|
||||||
|
if (actualVersion !== expectedVersion) {
|
||||||
|
throw new Error(
|
||||||
|
`SKILL.md version mismatch for ${path.basename(path.resolve(root))}: expected ${expectedVersion}, found ${actualVersion}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSkillFrontmatterVersion(source) {
|
||||||
|
const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(source);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
for (const line of match[1].split(/\r?\n/)) {
|
||||||
|
const versionMatch = /^version:\s*["']?([^"'\s#]+)["']?\s*(?:#.*)?$/.exec(line.trim());
|
||||||
|
if (versionMatch) return versionMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listReleaseFiles(root) {
|
export async function listReleaseFiles(root) {
|
||||||
const resolvedRoot = path.resolve(root);
|
const resolvedRoot = path.resolve(root);
|
||||||
const files = [];
|
const files = [];
|
||||||
@@ -53,6 +84,20 @@ export async function listReleaseFiles(root) {
|
|||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function findSkillMarkdown(root) {
|
||||||
|
const resolvedRoot = path.resolve(root);
|
||||||
|
for (const name of ["SKILL.md", "skill.md"]) {
|
||||||
|
const candidate = path.join(resolvedRoot, name);
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(candidate);
|
||||||
|
if (stat.isFile()) return candidate;
|
||||||
|
} catch {
|
||||||
|
// Try the next supported skill filename.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Missing SKILL.md in ${resolvedRoot}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function validateSelfContainedRelease(root) {
|
export async function validateSelfContainedRelease(root) {
|
||||||
const resolvedRoot = path.resolve(root);
|
const resolvedRoot = path.resolve(root);
|
||||||
const files = await listReleaseFiles(root);
|
const files = await listReleaseFiles(root);
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import test from "node:test";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
listReleaseFiles,
|
listReleaseFiles,
|
||||||
|
readSkillFrontmatterVersion,
|
||||||
|
readSkillMetadataVersion,
|
||||||
|
validateSkillMetadataVersion,
|
||||||
validateSelfContainedRelease,
|
validateSelfContainedRelease,
|
||||||
} from "./release-files.mjs";
|
} from "./release-files.mjs";
|
||||||
|
|
||||||
@@ -45,6 +48,34 @@ test("listReleaseFiles skips generated paths and returns sorted relative paths",
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("readSkillFrontmatterVersion reads quoted and unquoted versions", () => {
|
||||||
|
assert.equal(readSkillFrontmatterVersion("---\nname: demo\nversion: 1.2.3\n---\n"), "1.2.3");
|
||||||
|
assert.equal(readSkillFrontmatterVersion("---\nversion: \"2.0.0\"\n---\n"), "2.0.0");
|
||||||
|
assert.equal(readSkillFrontmatterVersion("# Missing frontmatter\n"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateSkillMetadataVersion accepts matching SKILL.md version", async (t) => {
|
||||||
|
const root = await makeTempDir("baoyu-release-version-ok-");
|
||||||
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
await writeFile(path.join(root, "SKILL.md"), "---\nname: demo\nversion: 1.2.3\n---\n");
|
||||||
|
|
||||||
|
assert.equal(await readSkillMetadataVersion(root), "1.2.3");
|
||||||
|
await assert.doesNotReject(() => validateSkillMetadataVersion(root, "1.2.3"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateSkillMetadataVersion rejects mismatched SKILL.md version", async (t) => {
|
||||||
|
const root = await makeTempDir("baoyu-release-version-mismatch-");
|
||||||
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
await writeFile(path.join(root, "SKILL.md"), "---\nname: demo\nversion: 1.2.3\n---\n");
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => validateSkillMetadataVersion(root, "1.2.4"),
|
||||||
|
/SKILL\.md version mismatch/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("validateSelfContainedRelease accepts file dependencies that stay within the release root", async (t) => {
|
test("validateSelfContainedRelease accepts file dependencies that stay within the release root", async (t) => {
|
||||||
const root = await makeTempDir("baoyu-release-ok-");
|
const root = await makeTempDir("baoyu-release-ok-");
|
||||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
const SKILL_PATH_PATTERN = /^skills\/([^/]+)\//;
|
||||||
|
const CONVENTIONAL_SUBJECT_PATTERN =
|
||||||
|
/^(?<type>[a-z][a-z0-9-]*)(?:\((?<scope>[^()\n]+)\))?(?<breaking>!)?: (?<description>\S[\s\S]*)$/;
|
||||||
|
|
||||||
|
export function parseConventionalCommitSubject(subject) {
|
||||||
|
const match = CONVENTIONAL_SUBJECT_PATTERN.exec(subject.trim());
|
||||||
|
if (!match?.groups) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: match.groups.type,
|
||||||
|
scope: match.groups.scope ?? "",
|
||||||
|
breaking: Boolean(match.groups.breaking),
|
||||||
|
description: match.groups.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changedSkillsForPaths(paths) {
|
||||||
|
const skills = new Set();
|
||||||
|
for (const filePath of paths) {
|
||||||
|
const normalizedPath = filePath.replaceAll("\\", "/");
|
||||||
|
const match = SKILL_PATH_PATTERN.exec(normalizedPath);
|
||||||
|
if (match) skills.add(match[1]);
|
||||||
|
}
|
||||||
|
return [...skills].sort((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSkillReleaseCommit({ commit = "", subject, paths }) {
|
||||||
|
const skills = changedSkillsForPaths(paths);
|
||||||
|
if (skills.length === 0) return [];
|
||||||
|
|
||||||
|
const parsed = parseConventionalCommitSubject(subject);
|
||||||
|
if (parsed) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
commit,
|
||||||
|
subject,
|
||||||
|
skills,
|
||||||
|
message: `Commit ${formatCommit(commit)} changes ${formatSkills(skills)} but its subject is not a Conventional Commit: ${subject}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSkillReleaseFailures(failures) {
|
||||||
|
if (failures.length === 0) return "";
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
"Skill release commit check failed.",
|
||||||
|
"",
|
||||||
|
"Commits that touch skills/<name>/** must use Conventional Commit subjects so per-skill release tooling can derive a version bump.",
|
||||||
|
"Example: fix(baoyu-post-to-wechat): handle WeChat editor focus",
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const failure of failures) {
|
||||||
|
lines.push(`- ${failure.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCommit(commit) {
|
||||||
|
return commit ? commit.slice(0, 12) : "<unknown>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSkills(skills) {
|
||||||
|
return skills.map((skill) => `skills/${skill}/**`).join(", ");
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
changedSkillsForPaths,
|
||||||
|
formatSkillReleaseFailures,
|
||||||
|
parseConventionalCommitSubject,
|
||||||
|
validateSkillReleaseCommit,
|
||||||
|
} from "./skill-release-guard.mjs";
|
||||||
|
|
||||||
|
test("parseConventionalCommitSubject accepts scoped, unscoped, and breaking subjects", () => {
|
||||||
|
assert.deepEqual(parseConventionalCommitSubject("fix(baoyu-post-to-wechat): repair editor paste"), {
|
||||||
|
type: "fix",
|
||||||
|
scope: "baoyu-post-to-wechat",
|
||||||
|
breaking: false,
|
||||||
|
description: "repair editor paste",
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseConventionalCommitSubject("feat!: change skill metadata format"), {
|
||||||
|
type: "feat",
|
||||||
|
scope: "",
|
||||||
|
breaking: true,
|
||||||
|
description: "change skill metadata format",
|
||||||
|
});
|
||||||
|
assert.equal(parseConventionalCommitSubject("Fix WeChat browser article publishing"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changedSkillsForPaths returns sorted unique skills", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
changedSkillsForPaths([
|
||||||
|
"README.md",
|
||||||
|
"skills/baoyu-post-to-wechat/scripts/wechat-article.ts",
|
||||||
|
"skills/baoyu-post-to-wechat/SKILL.md",
|
||||||
|
"skills/baoyu-url-to-markdown/SKILL.md",
|
||||||
|
]),
|
||||||
|
["baoyu-post-to-wechat", "baoyu-url-to-markdown"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateSkillReleaseCommit ignores non-skill changes", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
validateSkillReleaseCommit({
|
||||||
|
subject: "Fix test workflow",
|
||||||
|
paths: [".github/workflows/test.yml"],
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateSkillReleaseCommit rejects non-conventional skill commit subjects", () => {
|
||||||
|
const failures = validateSkillReleaseCommit({
|
||||||
|
commit: "81377416b4a7",
|
||||||
|
subject: "Fix WeChat browser article publishing",
|
||||||
|
paths: ["skills/baoyu-post-to-wechat/scripts/wechat-article.ts"],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(failures.length, 1);
|
||||||
|
assert.match(failures[0]!.message, /Conventional Commit/);
|
||||||
|
assert.match(formatSkillReleaseFailures(failures), /fix\(baoyu-post-to-wechat\):/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateSkillReleaseCommit accepts conventional skill commit subjects", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
validateSkillReleaseCommit({
|
||||||
|
subject: "fix(browser): ensure tab activation before copy/paste in WeChat editor",
|
||||||
|
paths: ["skills/baoyu-post-to-wechat/scripts/wechat-article.ts"],
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -5,7 +5,12 @@ import { existsSync } from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { listReleaseFiles, mimeType, validateSelfContainedRelease } from "./lib/release-files.mjs";
|
import {
|
||||||
|
listReleaseFiles,
|
||||||
|
mimeType,
|
||||||
|
validateSelfContainedRelease,
|
||||||
|
validateSkillMetadataVersion,
|
||||||
|
} from "./lib/release-files.mjs";
|
||||||
|
|
||||||
const DEFAULT_REGISTRY = "https://clawhub.ai";
|
const DEFAULT_REGISTRY = "https://clawhub.ai";
|
||||||
|
|
||||||
@@ -21,6 +26,7 @@ async function main() {
|
|||||||
? await fs.readFile(path.resolve(options.changelogFile), "utf8")
|
? await fs.readFile(path.resolve(options.changelogFile), "utf8")
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
await validateSkillMetadataVersion(skillDir, options.version);
|
||||||
await validateSelfContainedRelease(skillDir);
|
await validateSelfContainedRelease(skillDir);
|
||||||
const files = await listReleaseFiles(skillDir);
|
const files = await listReleaseFiles(skillDir);
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
|
|||||||
+46
-15
@@ -6,7 +6,13 @@ import { existsSync } from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
|
||||||
import { listReleaseFiles, mimeType, validateSelfContainedRelease } from "./lib/release-files.mjs";
|
import {
|
||||||
|
listReleaseFiles,
|
||||||
|
mimeType,
|
||||||
|
readSkillMetadataVersion,
|
||||||
|
validateSelfContainedRelease,
|
||||||
|
validateSkillMetadataVersion,
|
||||||
|
} from "./lib/release-files.mjs";
|
||||||
|
|
||||||
const DEFAULT_REGISTRY = "https://clawhub.ai";
|
const DEFAULT_REGISTRY = "https://clawhub.ai";
|
||||||
|
|
||||||
@@ -38,9 +44,11 @@ async function main() {
|
|||||||
|
|
||||||
const locals = await mapWithConcurrency(skills, options.concurrency, async (skill) => {
|
const locals = await mapWithConcurrency(skills, options.concurrency, async (skill) => {
|
||||||
const files = await collectReleaseFiles(skill.folder);
|
const files = await collectReleaseFiles(skill.folder);
|
||||||
|
const localVersion = await readSkillMetadataVersion(skill.folder);
|
||||||
const fingerprint = buildFingerprint(files);
|
const fingerprint = buildFingerprint(files);
|
||||||
return {
|
return {
|
||||||
...skill,
|
...skill,
|
||||||
|
localVersion,
|
||||||
fileCount: files.length,
|
fileCount: files.length,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
};
|
};
|
||||||
@@ -119,12 +127,12 @@ async function main() {
|
|||||||
for (const candidate of actionable) {
|
for (const candidate of actionable) {
|
||||||
const version =
|
const version =
|
||||||
candidate.status === "new"
|
candidate.status === "new"
|
||||||
? "1.0.0"
|
? candidate.localVersion
|
||||||
: bumpSemver(candidate.latestVersion, options.bump);
|
: resolveUpdateVersion(candidate, options.bump);
|
||||||
|
|
||||||
console.log(`Publishing ${candidate.slug}@${version}`);
|
console.log(`Publishing ${candidate.slug}@${version}`);
|
||||||
try {
|
try {
|
||||||
const files = await collectReleaseFiles(candidate.folder);
|
const files = await collectReleaseFiles(candidate.folder, version);
|
||||||
await publishSkill({
|
await publishSkill({
|
||||||
registry,
|
registry,
|
||||||
token: config.token,
|
token: config.token,
|
||||||
@@ -325,7 +333,10 @@ async function hasSkillMarker(folder) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectReleaseFiles(root) {
|
async function collectReleaseFiles(root, expectedVersion = "") {
|
||||||
|
if (expectedVersion) {
|
||||||
|
await validateSkillMetadataVersion(root, expectedVersion);
|
||||||
|
}
|
||||||
await validateSelfContainedRelease(root);
|
await validateSelfContainedRelease(root);
|
||||||
return listReleaseFiles(root);
|
return listReleaseFiles(root);
|
||||||
}
|
}
|
||||||
@@ -423,28 +434,48 @@ async function mapWithConcurrency(items, limit, fn) {
|
|||||||
|
|
||||||
function formatCandidate(candidate, bump) {
|
function formatCandidate(candidate, bump) {
|
||||||
if (candidate.status === "new") {
|
if (candidate.status === "new") {
|
||||||
return `${candidate.slug} NEW (${candidate.fileCount} files)`;
|
return `${candidate.slug} NEW ${candidate.localVersion} (${candidate.fileCount} files)`;
|
||||||
}
|
}
|
||||||
return `${candidate.slug} UPDATE ${candidate.latestVersion} -> ${bumpSemver(
|
return `${candidate.slug} UPDATE ${candidate.latestVersion} -> ${resolveUpdateVersion(
|
||||||
candidate.latestVersion,
|
candidate,
|
||||||
bump
|
bump
|
||||||
)} (${candidate.fileCount} files)`;
|
)} (${candidate.fileCount} files)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bumpSemver(version, bump) {
|
function resolveUpdateVersion(candidate, bump) {
|
||||||
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version ?? "");
|
if (compareSemver(candidate.localVersion, candidate.latestVersion) > 0) {
|
||||||
if (!match) {
|
return candidate.localVersion;
|
||||||
throw new Error(`Invalid semver: ${version}`);
|
|
||||||
}
|
}
|
||||||
const major = Number(match[1]);
|
return bumpSemver(candidate.latestVersion, bump);
|
||||||
const minor = Number(match[2]);
|
}
|
||||||
const patch = Number(match[3]);
|
|
||||||
|
function compareSemver(left, right) {
|
||||||
|
const leftParts = parseSemver(left);
|
||||||
|
const rightParts = parseSemver(right);
|
||||||
|
for (let index = 0; index < leftParts.length; index += 1) {
|
||||||
|
if (leftParts[index] !== rightParts[index]) {
|
||||||
|
return leftParts[index] - rightParts[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bumpSemver(version, bump) {
|
||||||
|
const [major, minor, patch] = parseSemver(version);
|
||||||
|
|
||||||
if (bump === "major") return `${major + 1}.0.0`;
|
if (bump === "major") return `${major + 1}.0.0`;
|
||||||
if (bump === "minor") return `${major}.${minor + 1}.0`;
|
if (bump === "minor") return `${major}.${minor + 1}.0`;
|
||||||
return `${major}.${minor}.${patch + 1}`;
|
return `${major}.${minor}.${patch + 1}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseSemver(version) {
|
||||||
|
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version ?? "");
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Invalid semver: ${version}`);
|
||||||
|
}
|
||||||
|
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeSlug(value) {
|
function sanitizeSlug(value) {
|
||||||
return value
|
return value
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatSkillReleaseFailures,
|
||||||
|
validateSkillReleaseCommit,
|
||||||
|
} from "./lib/skill-release-guard.mjs";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const ZERO_SHA = /^0{40}$/;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
const range = await resolveRange(options);
|
||||||
|
const commits = await listCommits(range);
|
||||||
|
|
||||||
|
const failures = [];
|
||||||
|
for (const commit of commits) {
|
||||||
|
const [subject, paths] = await Promise.all([readCommitSubject(commit), readCommitPaths(commit)]);
|
||||||
|
failures.push(...validateSkillReleaseCommit({ commit, subject, paths }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error(formatSkillReleaseFailures(failures));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Skill release commit check passed (${commits.length} commit${commits.length === 1 ? "" : "s"} checked).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const options = {
|
||||||
|
base: "",
|
||||||
|
head: "HEAD",
|
||||||
|
range: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const arg = argv[index];
|
||||||
|
if (arg === "--base") {
|
||||||
|
options.base = argv[index + 1] ?? "";
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--head") {
|
||||||
|
options.head = argv[index + 1] ?? "";
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--range") {
|
||||||
|
options.range = argv[index + 1] ?? "";
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "-h" || arg === "--help") {
|
||||||
|
printUsage();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
throw new Error(`Unknown argument: ${arg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
console.log(`Usage: verify-skill-release-commits.mjs [--base <rev> --head <rev> | --range <rev-range>]
|
||||||
|
|
||||||
|
Checks non-merge commits in the selected range. Any commit that touches
|
||||||
|
skills/<name>/** must use a Conventional Commit subject, for example:
|
||||||
|
fix(baoyu-post-to-wechat): handle WeChat editor focus
|
||||||
|
|
||||||
|
Without explicit arguments, GitHub Actions event metadata is used when
|
||||||
|
available. Otherwise the fallback range is HEAD^..HEAD.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRange(options) {
|
||||||
|
if (options.range) {
|
||||||
|
return { args: [options.range], label: options.range };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.base) {
|
||||||
|
return {
|
||||||
|
args: [`${options.base}..${options.head || "HEAD"}`],
|
||||||
|
label: `${options.base}..${options.head || "HEAD"}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const githubRange = await resolveGitHubRange();
|
||||||
|
if (githubRange) return githubRange;
|
||||||
|
|
||||||
|
return { args: ["HEAD^..HEAD"], label: "HEAD^..HEAD" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveGitHubRange() {
|
||||||
|
const eventPath = process.env.GITHUB_EVENT_PATH;
|
||||||
|
if (!eventPath) return null;
|
||||||
|
|
||||||
|
let event = null;
|
||||||
|
try {
|
||||||
|
event = JSON.parse(await fs.readFile(eventPath, "utf8"));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event?.pull_request?.base?.sha) {
|
||||||
|
const base = event.pull_request.base.sha;
|
||||||
|
const head = process.env.GITHUB_SHA || "HEAD";
|
||||||
|
return { args: [`${base}..${head}`], label: `${base}..${head}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event?.before && !ZERO_SHA.test(event.before)) {
|
||||||
|
const head = event.after || process.env.GITHUB_SHA || "HEAD";
|
||||||
|
return { args: [`${event.before}..${head}`], label: `${event.before}..${head}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listCommits(range) {
|
||||||
|
const output = await git(["rev-list", "--no-merges", "--reverse", ...range.args]);
|
||||||
|
return output ? output.split("\n").filter(Boolean) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCommitSubject(commit) {
|
||||||
|
return git(["log", "-1", "--format=%s", commit]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCommitPaths(commit) {
|
||||||
|
const output = await git(["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", commit]);
|
||||||
|
return output ? output.split("\n").filter(Boolean) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function git(args) {
|
||||||
|
const { stdout } = await execFileAsync("git", args, {
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
return stdout.trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: baoyu-article-illustrator
|
name: baoyu-article-illustrator
|
||||||
description: Analyzes article structure, identifies positions requiring visual aids, generates illustrations with Type × Style × Palette three-dimension approach. Use when user asks to "illustrate article", "add images", "generate images for article", or "为文章配图".
|
description: Analyzes article structure, identifies positions requiring visual aids, generates illustrations with Type × Style × Palette three-dimension approach. Use when user asks to "illustrate article", "add images", "generate images for article", or "为文章配图".
|
||||||
version: 1.58.0
|
version: 1.59.0
|
||||||
metadata:
|
metadata:
|
||||||
openclaw:
|
openclaw:
|
||||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-article-illustrator
|
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-article-illustrator
|
||||||
@@ -36,12 +36,30 @@ When this skill needs to render an image, resolve the backend in this order:
|
|||||||
|
|
||||||
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
|
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
|
||||||
|
|
||||||
|
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace labels, captions, or any other text inside an already generated illustration. If text is wrong or unclear, regenerate from a corrected prompt, redraw with less or no on-image text, or ask the user which imperfect candidate to keep.
|
||||||
|
|
||||||
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
|
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
|
||||||
|
|
||||||
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
|
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
|
||||||
|
|
||||||
Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) above are examples — substitute the local equivalents under the same rule.
|
Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) above are examples — substitute the local equivalents under the same rule.
|
||||||
|
|
||||||
|
## Batch Generation Policy
|
||||||
|
|
||||||
|
After every prompt file for the run has been saved and verified, generate images in batches by default.
|
||||||
|
|
||||||
|
Priority order:
|
||||||
|
|
||||||
|
1. Use the chosen backend's native batch / multi-task interface if it exists. Each task must keep its own prompt file, output path, aspect ratio, and direct reference images.
|
||||||
|
2. If no native batch interface exists but the runtime can issue parallel tool calls, dispatch up to `generation_batch_size` images at a time. Default: `4`. An explicit user request in the current message, such as `--batch-size 4` or "并行4张一起生成", overrides EXTEND.md.
|
||||||
|
3. If neither native batch nor parallel tool calls are available, generate sequentially.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Never start the first batch until all prompt files for that batch exist on disk.
|
||||||
|
- Retry failed items once without regenerating successful items.
|
||||||
|
- Do not use subagents merely to parallelize image rendering. Use subagents only for separate prompt iteration or creative exploration.
|
||||||
|
|
||||||
## Confirmation Policy
|
## Confirmation Policy
|
||||||
|
|
||||||
Default behavior: **confirm before generation**.
|
Default behavior: **confirm before generation**.
|
||||||
@@ -167,7 +185,7 @@ Full template: [references/workflow.md](references/workflow.md#step-4-generate-o
|
|||||||
4. LABELS **MUST** include article-specific data: actual numbers, terms, metrics, quotes
|
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
|
5. **DO NOT** pass ad-hoc inline prompts to `--prompt` without saving prompt files first
|
||||||
6. Select the backend via the `## Image Generation Tools` rule at the top: use whatever is available; if multiple, ask the user once. Do this once per session before any generation.
|
6. Select the backend via the `## Image Generation Tools` rule at the top: use whatever is available; if multiple, ask the user once. Do this once per session before any generation.
|
||||||
7. **Execution strategy**: When multiple illustrations have saved prompt files and the task is now plain generation, prefer the chosen backend's batch interface (if it offers one) over spawning subagents. Use subagents only when each image still needs separate prompt iteration or creative exploration. If the backend has no batch interface, generate sequentially.
|
7. **Execution strategy**: Generate in batches per the `## Batch Generation Policy`: backend native batch first, runtime parallel tool calls second, sequential only as fallback. Default batch size is 4 unless EXTEND.md or the current request overrides it.
|
||||||
8. Process references (`direct`/`style`/`palette`) per prompt frontmatter
|
8. Process references (`direct`/`style`/`palette`) per prompt frontmatter
|
||||||
9. Apply watermark if EXTEND.md enabled
|
9. Apply watermark if EXTEND.md enabled
|
||||||
10. Generate from saved prompt files; retry once on failure
|
10. Generate from saved prompt files; retry once on failure
|
||||||
@@ -217,6 +235,12 @@ When input is **pasted content** (no file path), always uses `illustrations/{top
|
|||||||
| Add | Position → Prompt → Generate → Update outline → Insert |
|
| Add | Position → Prompt → Generate → Update outline → Insert |
|
||||||
| Delete | Delete files → Remove reference → Update outline |
|
| Delete | Delete files → Remove reference → Update outline |
|
||||||
|
|
||||||
|
Text correction policy:
|
||||||
|
|
||||||
|
- If any rendered text (labels, captions, etc.) is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
|
||||||
|
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
|
||||||
|
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
| File | Content |
|
| File | Content |
|
||||||
@@ -239,5 +263,6 @@ EXTEND.md lives at the first matching path listed in Step 1.5. Three ways to cha
|
|||||||
- `preferred_image_backend: codex-imagegen` — pin to Codex's built-in.
|
- `preferred_image_backend: codex-imagegen` — pin to Codex's built-in.
|
||||||
- `preferred_image_backend: baoyu-imagine` — pin to the baoyu-imagine skill.
|
- `preferred_image_backend: baoyu-imagine` — pin to the baoyu-imagine skill.
|
||||||
- `preferred_image_backend: ask` — confirm backend every run.
|
- `preferred_image_backend: ask` — confirm backend every run.
|
||||||
|
- `generation_batch_size: 4` — default number of images to render concurrently when the runtime supports parallel generation calls.
|
||||||
- `preferred_type: infographic`, `preferred_style: notion`, `preferred_palette: macaron`, `language: zh`.
|
- `preferred_type: infographic`, `preferred_style: notion`, `preferred_palette: macaron`, `language: zh`.
|
||||||
- `default_output_dir: imgs-subdir` — where to write generated images relative to the article.
|
- `default_output_dir: imgs-subdir` — where to write generated images relative to the article.
|
||||||
|
|||||||
@@ -129,12 +129,15 @@ preferred_style:
|
|||||||
default_output_dir: imgs-subdir # same-dir | imgs-subdir | illustrations-subdir | independent
|
default_output_dir: imgs-subdir # same-dir | imgs-subdir | illustrations-subdir | independent
|
||||||
language: null
|
language: null
|
||||||
preferred_image_backend: auto
|
preferred_image_backend: auto
|
||||||
|
generation_batch_size: 4
|
||||||
custom_styles: []
|
custom_styles: []
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
`preferred_image_backend: auto` is the baked-in default — first-time setup does not ask about it. The `## Image Generation Tools` rule in SKILL.md then picks the runtime-native tool (Codex `imagegen`, Hermes `image_generate`, etc.) when available, and falls back to installed backends.
|
`preferred_image_backend: auto` is the baked-in default — first-time setup does not ask about it. The `## Image Generation Tools` rule in SKILL.md then picks the runtime-native tool (Codex `imagegen`, Hermes `image_generate`, etc.) when available, and falls back to installed backends.
|
||||||
|
|
||||||
|
`generation_batch_size: 4` is the baked-in default for batch rendering. The current user request may override it for one run.
|
||||||
|
|
||||||
## Modifying Preferences Later
|
## Modifying Preferences Later
|
||||||
|
|
||||||
See the `## Changing Preferences` section in `SKILL.md` for the canonical list of common edits (pin backend, change defaults, retrigger setup). Full schema: `preferences-schema.md`.
|
See the `## Changing Preferences` section in `SKILL.md` for the canonical list of common edits (pin backend, change defaults, retrigger setup). Full schema: `preferences-schema.md`.
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ default_output_dir: null # same-dir|illustrations-subdir|independent
|
|||||||
|
|
||||||
preferred_image_backend: auto # auto|ask|<backend-id>
|
preferred_image_backend: auto # auto|ask|<backend-id>
|
||||||
|
|
||||||
|
generation_batch_size: 4 # 1-8, used when backend/runtime supports batch or parallel generation
|
||||||
|
|
||||||
custom_styles:
|
custom_styles:
|
||||||
- name: my-style
|
- name: my-style
|
||||||
description: "Style description"
|
description: "Style description"
|
||||||
@@ -55,6 +57,7 @@ custom_styles:
|
|||||||
| `language` | string | null | Output language (null = auto-detect) |
|
| `language` | string | null | Output language (null = auto-detect) |
|
||||||
| `default_output_dir` | enum | null | Output directory preference (null = ask each time) |
|
| `default_output_dir` | enum | null | Output directory preference (null = ask each time) |
|
||||||
| `preferred_image_backend` | string | `auto` | Image backend selection. `auto` = prefer runtime-native tool, fall back to the only installed backend, ask if multiple non-native are present. `ask` = always confirm on every run. `<backend-id>` (e.g., `codex-imagegen`, `baoyu-imagine`, `image_generate`) = pin this backend when available; fall back to `auto` when it isn't. Absent = `auto`. Resolution logic is documented in `SKILL.md`'s `## Image Generation Tools` section. |
|
| `preferred_image_backend` | string | `auto` | Image backend selection. `auto` = prefer runtime-native tool, fall back to the only installed backend, ask if multiple non-native are present. `ask` = always confirm on every run. `<backend-id>` (e.g., `codex-imagegen`, `baoyu-imagine`, `image_generate`) = pin this backend when available; fall back to `auto` when it isn't. Absent = `auto`. Resolution logic is documented in `SKILL.md`'s `## Image Generation Tools` section. |
|
||||||
|
| `generation_batch_size` | int | 4 | Number of images to dispatch per batch when the backend has native batch support or the runtime can issue parallel generation calls. Clamp invalid values to 1-8. Current user request overrides this value. |
|
||||||
| `custom_styles` | array | [] | User-defined styles |
|
| `custom_styles` | array | [] | User-defined styles |
|
||||||
|
|
||||||
## Position Options
|
## Position Options
|
||||||
@@ -118,6 +121,8 @@ language: zh
|
|||||||
|
|
||||||
preferred_image_backend: codex-imagegen
|
preferred_image_backend: codex-imagegen
|
||||||
|
|
||||||
|
generation_batch_size: 4
|
||||||
|
|
||||||
custom_styles:
|
custom_styles:
|
||||||
- name: corporate
|
- name: corporate
|
||||||
description: "Professional B2B style"
|
description: "Professional B2B style"
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
# Specify density
|
# Specify density
|
||||||
/baoyu-article-illustrator path/to/article.md --density rich
|
/baoyu-article-illustrator path/to/article.md --density rich
|
||||||
|
|
||||||
|
# Generate up to 4 images in parallel after prompts are saved
|
||||||
|
/baoyu-article-illustrator path/to/article.md --batch-size 4
|
||||||
|
|
||||||
# Direct content input (paste mode)
|
# Direct content input (paste mode)
|
||||||
/baoyu-article-illustrator
|
/baoyu-article-illustrator
|
||||||
[paste content]
|
[paste content]
|
||||||
@@ -31,6 +34,7 @@
|
|||||||
| `--style <name>` | Visual style (see references/styles.md) |
|
| `--style <name>` | Visual style (see references/styles.md) |
|
||||||
| `--preset <name>` | Shorthand for type + style combo (see [references/style-presets.md](references/style-presets.md)) |
|
| `--preset <name>` | Shorthand for type + style combo (see [references/style-presets.md](references/style-presets.md)) |
|
||||||
| `--density <level>` | Image count: minimal / balanced / rich |
|
| `--density <level>` | Image count: minimal / balanced / rich |
|
||||||
|
| `--batch-size <n>` | Temporary generation batch size for this run. Default: `generation_batch_size` from EXTEND.md, otherwise 4. Clamp to 1-8. |
|
||||||
|
|
||||||
## Input Modes
|
## Input Modes
|
||||||
|
|
||||||
|
|||||||
@@ -335,8 +335,11 @@ Prompt Files:
|
|||||||
**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`.
|
**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`.
|
||||||
|
|
||||||
**Execution choice**:
|
**Execution choice**:
|
||||||
- If multiple illustrations already have saved prompt files and the task is now plain generation, prefer the chosen backend's batch interface (if it offers one); otherwise generate sequentially
|
- If multiple illustrations already have saved prompt files and the task is now plain generation, use batch generation by default.
|
||||||
- Use subagents only when each illustration still needs separate prompt rewriting, style exploration, or other per-image reasoning before generation
|
- Prefer the chosen backend's native batch / multi-task interface when available.
|
||||||
|
- If the backend has no native batch interface but the runtime can issue parallel tool calls, dispatch up to `generation_batch_size` tasks at a time. Default: `4`. The current user request overrides EXTEND.md.
|
||||||
|
- Generate sequentially only when neither backend batch nor runtime parallel calls are available.
|
||||||
|
- Use subagents only when each illustration still needs separate prompt rewriting, style exploration, or other per-image reasoning before generation. Do not use subagents just to parallelize rendering.
|
||||||
|
|
||||||
**CRITICAL - References in Frontmatter**:
|
**CRITICAL - References in Frontmatter**:
|
||||||
- Only add `references` field if files ACTUALLY EXIST in `references/` directory
|
- Only add `references` field if files ACTUALLY EXIST in `references/` directory
|
||||||
@@ -394,12 +397,18 @@ Add: `Include a subtle watermark "[content]" at [position].`
|
|||||||
|
|
||||||
### 5.5 Generate
|
### 5.5 Generate
|
||||||
|
|
||||||
1. For each illustration:
|
1. Build a generation task list from saved prompt files:
|
||||||
- **Backup rule**: If image file exists, rename to `NN-{type}-{slug}-backup-YYYYMMDD-HHMMSS.md`
|
- `prompt_file`: `{output-dir}/prompts/NN-{type}-{slug}.md`
|
||||||
- If references with `direct` usage: include `--ref` parameter
|
- `output_file`: `{output-dir}/NN-{type}-{slug}.png`
|
||||||
- Generate image
|
- `aspect_ratio`: from prompt frontmatter or prompt body
|
||||||
2. After each: "Generated X/N"
|
- `refs`: only verified `direct` references from prompt frontmatter
|
||||||
3. On failure: retry once, then log and continue
|
2. **Backup rule**: Before dispatching a task, if its output image already exists, rename it to `NN-{type}-{slug}-backup-YYYYMMDD-HHMMSS.{ext}`.
|
||||||
|
3. Dispatch tasks in batches:
|
||||||
|
- Native batch backend: send all eligible tasks, or chunks of `generation_batch_size` if the backend has a practical limit.
|
||||||
|
- Runtime parallel calls: issue up to `generation_batch_size` image calls concurrently, then continue with the next chunk.
|
||||||
|
- Sequential fallback: process one task at a time.
|
||||||
|
4. After each completed task, record: "Generated X/N: filename".
|
||||||
|
5. On failure: retry the failed task once from the same saved prompt file. Keep successful outputs and continue.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: baoyu-comic
|
name: baoyu-comic
|
||||||
description: Knowledge comic creator supporting multiple art styles and tones. Creates original educational comics with detailed panel layouts and sequential image generation. Use when user asks to create "知识漫画", "教育漫画", "biography comic", "tutorial comic", or "Logicomix-style comic".
|
description: Knowledge comic creator supporting multiple art styles and tones. Creates original educational comics with detailed panel layouts and batch-capable image generation. Use when user asks to create "知识漫画", "教育漫画", "biography comic", "tutorial comic", or "Logicomix-style comic".
|
||||||
version: 1.56.1
|
version: 1.57.0
|
||||||
metadata:
|
metadata:
|
||||||
openclaw:
|
openclaw:
|
||||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-comic
|
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-comic
|
||||||
@@ -40,12 +40,31 @@ When this skill needs to render an image, resolve the backend in this order:
|
|||||||
|
|
||||||
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
|
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
|
||||||
|
|
||||||
|
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace dialogue, sound effects, panel labels, or any other text inside an already generated comic page. If text is wrong or unclear, regenerate from a corrected prompt, redraw the page with less or no on-image text, or ask the user which imperfect candidate to keep.
|
||||||
|
|
||||||
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
|
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
|
||||||
|
|
||||||
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
|
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
|
||||||
|
|
||||||
Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) above are examples — substitute the local equivalents under the same rule.
|
Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) above are examples — substitute the local equivalents under the same rule.
|
||||||
|
|
||||||
|
## Batch Generation Policy
|
||||||
|
|
||||||
|
After every prompt file for the current generation group has been saved and verified, generate images in batches by default.
|
||||||
|
|
||||||
|
Priority order:
|
||||||
|
|
||||||
|
1. Use the chosen backend's native batch / multi-task interface if it exists. Each task must keep its own prompt file, output path, aspect ratio, session ID, and direct reference images.
|
||||||
|
2. If no native batch interface exists but the runtime can issue parallel tool calls, dispatch up to `generation_batch_size` images at a time. Default: `4`. An explicit user request in the current message, such as `--batch-size 4` or "并行4张一起生成", overrides EXTEND.md.
|
||||||
|
3. If neither native batch nor parallel tool calls are available, generate sequentially.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Honor workflow dependencies first: generate `characters/characters.png` before pages that use it as a reference.
|
||||||
|
- Never start the first page batch until all selected page prompt files exist on disk.
|
||||||
|
- Retry failed items once without regenerating successful items.
|
||||||
|
- Do not use subagents merely to parallelize image rendering. Use subagents only for separate prompt iteration or creative exploration.
|
||||||
|
|
||||||
## Reference Images
|
## Reference Images
|
||||||
|
|
||||||
Users may supply reference images to guide art style, palette, scene composition, or subject. This is **separate from** the auto-generated character sheet (Step 7.1) — both can coexist: user refs guide the look, the character sheet anchors recurring character identity.
|
Users may supply reference images to guide art style, palette, scene composition, or subject. This is **separate from** the auto-generated character sheet (Step 7.1) — both can coexist: user refs guide the look, the character sheet anchors recurring character identity.
|
||||||
@@ -90,6 +109,7 @@ references:
|
|||||||
| `--aspect` | 3:4 (default, portrait), 4:3 (landscape), 16:9 (widescreen) | Page aspect ratio |
|
| `--aspect` | 3:4 (default, portrait), 4:3 (landscape), 16:9 (widescreen) | Page aspect ratio |
|
||||||
| `--lang` | auto (default), zh, en, ja, etc. | Output language |
|
| `--lang` | auto (default), zh, en, ja, etc. | Output language |
|
||||||
| `--ref <files...>` | File paths | Reference images applied to every page for style / palette / scene guidance. See [Reference Images](#reference-images) above. |
|
| `--ref <files...>` | File paths | Reference images applied to every page for style / palette / scene guidance. See [Reference Images](#reference-images) above. |
|
||||||
|
| `--batch-size <n>` | 1-8 | Temporary page generation batch size for this run. Default: `generation_batch_size` from EXTEND.md, otherwise 4. |
|
||||||
|
|
||||||
### Partial Workflow Options
|
### Partial Workflow Options
|
||||||
|
|
||||||
@@ -237,6 +257,8 @@ Analyze → [Check Existing?] → [Confirm: Style + Reviews] → Storyboard →
|
|||||||
| Exists | Not supported | Prepend character descriptions to every prompt file |
|
| Exists | Not supported | Prepend character descriptions to every prompt file |
|
||||||
| Skipped | — | All descriptions inline in prompt |
|
| Skipped | — | All descriptions inline in prompt |
|
||||||
|
|
||||||
|
**Execution strategy**: Generate the character sheet first when needed. Then build the selected page task list from saved prompt files and dispatch pages in batches per the `## Batch Generation Policy`: backend native batch first, runtime parallel tool calls second, sequential only as fallback. `--regenerate N` and `--images-only` apply the same batching rules to the selected existing prompts.
|
||||||
|
|
||||||
**Backup rule**: existing `prompts/…md` and `…png` files → rename with `-backup-YYYYMMDD-HHMMSS` suffix before regenerating. Aspect ratio from storyboard (default `3:4`; preset may override).
|
**Backup rule**: existing `prompts/…md` and `…png` files → rename with `-backup-YYYYMMDD-HHMMSS` suffix before regenerating. Aspect ratio from storyboard (default `3:4`; preset may override).
|
||||||
|
|
||||||
**`--ref` failure recovery**: compress sheet → retry → still fails → drop `--ref` and embed character descriptions in the prompt text.
|
**`--ref` failure recovery**: compress sheet → retry → still fails → drop `--ref` and embed character descriptions in the prompt text.
|
||||||
@@ -257,7 +279,7 @@ If EXTEND.md is not found, first-time setup is **blocking** — complete it befo
|
|||||||
| Found | Read, parse, display summary → continue |
|
| Found | Read, parse, display summary → continue |
|
||||||
| Not found | ⛔ Run first-time setup ([references/config/first-time-setup.md](references/config/first-time-setup.md)) → save EXTEND.md → continue |
|
| Not found | ⛔ Run first-time setup ([references/config/first-time-setup.md](references/config/first-time-setup.md)) → save EXTEND.md → 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).
|
**EXTEND.md supports**: watermark, preferred art/tone/layout, custom style definitions, character presets, language preference, preferred image backend, generation batch size. Schema: [references/config/preferences-schema.md](references/config/preferences-schema.md).
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
@@ -293,6 +315,12 @@ If EXTEND.md is not found, first-time setup is **blocking** — complete it befo
|
|||||||
|
|
||||||
**IMPORTANT**: When updating pages, ALWAYS update the prompt file (`prompts/NN-{cover|page}-[slug].md`) FIRST before regenerating. This ensures changes are documented and reproducible.
|
**IMPORTANT**: When updating pages, ALWAYS update the prompt file (`prompts/NN-{cover|page}-[slug].md`) FIRST before regenerating. This ensures changes are documented and reproducible.
|
||||||
|
|
||||||
|
Text correction policy:
|
||||||
|
|
||||||
|
- If dialogue, sound effects, panel labels, or any other rendered text is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
|
||||||
|
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
|
||||||
|
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Image generation: 10-30 seconds per page
|
- Image generation: 10-30 seconds per page
|
||||||
@@ -316,4 +344,5 @@ EXTEND.md lives at `.baoyu-skills/baoyu-comic/EXTEND.md` (project) or `~/.baoyu-
|
|||||||
- `preferred_image_backend: codex-imagegen` — pin to Codex's built-in.
|
- `preferred_image_backend: codex-imagegen` — pin to Codex's built-in.
|
||||||
- `preferred_image_backend: baoyu-imagine` — pin to the baoyu-imagine skill.
|
- `preferred_image_backend: baoyu-imagine` — pin to the baoyu-imagine skill.
|
||||||
- `preferred_image_backend: ask` — confirm backend every run.
|
- `preferred_image_backend: ask` — confirm backend every run.
|
||||||
|
- `generation_batch_size: 4` — default number of page images to render concurrently when the backend/runtime supports batch or parallel generation.
|
||||||
- `watermark.enabled: true`, `preferred_art`, `preferred_tone`, `preferred_layout`, `language` — shift the auto-selection defaults and cosmetic choices.
|
- `watermark.enabled: true`, `preferred_art`, `preferred_tone`, `preferred_layout`, `language` — shift the auto-selection defaults and cosmetic choices.
|
||||||
|
|||||||
@@ -143,12 +143,15 @@ preferred_layout: null
|
|||||||
preferred_aspect: null
|
preferred_aspect: null
|
||||||
language: [selected or null]
|
language: [selected or null]
|
||||||
preferred_image_backend: auto
|
preferred_image_backend: auto
|
||||||
|
generation_batch_size: 4
|
||||||
character_presets: []
|
character_presets: []
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
`preferred_image_backend: auto` is the baked-in default — first-time setup does not ask about it. The `## Image Generation Tools` rule in SKILL.md then picks the runtime-native tool (Codex `imagegen`, Hermes `image_generate`, etc.) when available, and falls back to installed backends.
|
`preferred_image_backend: auto` is the baked-in default — first-time setup does not ask about it. The `## Image Generation Tools` rule in SKILL.md then picks the runtime-native tool (Codex `imagegen`, Hermes `image_generate`, etc.) when available, and falls back to installed backends.
|
||||||
|
|
||||||
|
`generation_batch_size: 4` is the baked-in default for page batch rendering. The current user request may override it for one run.
|
||||||
|
|
||||||
## Modifying Preferences Later
|
## Modifying Preferences Later
|
||||||
|
|
||||||
See the `## Changing Preferences` section in `SKILL.md` for the canonical list of common edits (pin backend, change defaults, retrigger setup). Full schema: `config/preferences-schema.md`.
|
See the `## Changing Preferences` section in `SKILL.md` for the canonical list of common edits (pin backend, change defaults, retrigger setup). Full schema: `config/preferences-schema.md`.
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ language: null # zh|en|ja|ko|auto
|
|||||||
|
|
||||||
preferred_image_backend: auto # auto|ask|<backend-id>
|
preferred_image_backend: auto # auto|ask|<backend-id>
|
||||||
|
|
||||||
|
generation_batch_size: 4 # 1-8, used when backend/runtime supports batch or parallel page generation
|
||||||
|
|
||||||
character_presets:
|
character_presets:
|
||||||
- name: my-characters
|
- name: my-characters
|
||||||
roles:
|
roles:
|
||||||
@@ -49,6 +51,7 @@ character_presets:
|
|||||||
| `preferred_aspect` | string | null | Aspect ratio (3:4, 4:3, 16:9) |
|
| `preferred_aspect` | string | null | Aspect ratio (3:4, 4:3, 16:9) |
|
||||||
| `language` | string | null | Output language (null = auto-detect) |
|
| `language` | string | null | Output language (null = auto-detect) |
|
||||||
| `preferred_image_backend` | string | `auto` | Image backend selection. `auto` = prefer runtime-native tool, fall back to the only installed backend, ask if multiple non-native are present. `ask` = always confirm on every run. `<backend-id>` (e.g., `codex-imagegen`, `baoyu-imagine`, `image_generate`) = pin this backend when available; fall back to `auto` when it isn't. Absent = `auto`. Resolution logic is documented in `SKILL.md`'s `## Image Generation Tools` section. |
|
| `preferred_image_backend` | string | `auto` | Image backend selection. `auto` = prefer runtime-native tool, fall back to the only installed backend, ask if multiple non-native are present. `ask` = always confirm on every run. `<backend-id>` (e.g., `codex-imagegen`, `baoyu-imagine`, `image_generate`) = pin this backend when available; fall back to `auto` when it isn't. Absent = `auto`. Resolution logic is documented in `SKILL.md`'s `## Image Generation Tools` section. |
|
||||||
|
| `generation_batch_size` | int | 4 | Number of page images to dispatch per batch when the backend has native batch support or the runtime can issue parallel generation calls. Clamp invalid values to 1-8. Current user request overrides this value. |
|
||||||
| `character_presets` | array | [] | Preset character roles for styles like ohmsha |
|
| `character_presets` | array | [] | Preset character roles for styles like ohmsha |
|
||||||
|
|
||||||
## Art Style Options
|
## Art Style Options
|
||||||
@@ -127,6 +130,8 @@ language: zh
|
|||||||
|
|
||||||
preferred_image_backend: codex-imagegen
|
preferred_image_backend: codex-imagegen
|
||||||
|
|
||||||
|
generation_batch_size: 4
|
||||||
|
|
||||||
character_presets:
|
character_presets:
|
||||||
- name: tech-tutorial
|
- name: tech-tutorial
|
||||||
roles:
|
roles:
|
||||||
|
|||||||
@@ -488,12 +488,19 @@ When character sheet was skipped or `--ref` failed:
|
|||||||
- No `--ref` parameter needed
|
- No `--ref` parameter needed
|
||||||
- Rely on detailed text descriptions for character consistency
|
- Rely on detailed text descriptions for character consistency
|
||||||
|
|
||||||
**For each page (cover + pages)**:
|
**Page batch generation (cover + pages)**:
|
||||||
1. Read prompt from `prompts/NN-{cover|page}-[slug].md`
|
1. Build a page task list from selected saved prompts:
|
||||||
2. **Backup rule**: If image file exists, rename to `NN-{cover|page}-[slug]-backup-YYYYMMDD-HHMMSS.png`
|
- `prompt_file`: `prompts/NN-{cover|page}-[slug].md`
|
||||||
3. Generate image using Strategy A, B, or C
|
- `output_file`: `NN-{cover|page}-[slug].png`
|
||||||
4. Save to `NN-{cover|page}-[slug].png`
|
- `aspect_ratio`: from storyboard (default `3:4`; preset may override)
|
||||||
5. Report progress after each generation: "Generated X/N: [page title]"
|
- `refs`: character sheet and verified direct user refs when Strategy A is active
|
||||||
|
2. **Backup rule**: Before dispatching a task, if its image file exists, rename it to `NN-{cover|page}-[slug]-backup-YYYYMMDD-HHMMSS.png`.
|
||||||
|
3. Dispatch tasks in batches:
|
||||||
|
- Native batch backend: send all eligible page tasks, or chunks of `generation_batch_size` if the backend has a practical limit.
|
||||||
|
- Runtime parallel calls: issue up to `generation_batch_size` image calls concurrently, then continue with the next chunk.
|
||||||
|
- Sequential fallback: process one page at a time.
|
||||||
|
4. After each completed task, report: "Generated X/N: [page title]".
|
||||||
|
5. On failure, retry the failed task once from the same saved prompt file. Keep successful outputs and continue.
|
||||||
|
|
||||||
**Session Management**:
|
**Session Management**:
|
||||||
If image generation skill supports `--sessionId`:
|
If image generation skill supports `--sessionId`:
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ When this skill needs to render an image, resolve the backend in this order:
|
|||||||
|
|
||||||
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
|
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
|
||||||
|
|
||||||
|
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace title/subtitle text inside an already generated cover image. If text is wrong or unclear, regenerate from a corrected prompt, switch to a lower-text or no-title variant, or ask the user which imperfect candidate to keep.
|
||||||
|
|
||||||
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
|
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
|
||||||
|
|
||||||
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
|
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
|
||||||
@@ -241,6 +243,12 @@ Files:
|
|||||||
| **Regenerate** | Backup → Update prompt file FIRST → Regenerate |
|
| **Regenerate** | Backup → Update prompt file FIRST → Regenerate |
|
||||||
| **Change dimension** | Backup → Confirm new value → Update prompt → Regenerate |
|
| **Change dimension** | Backup → Confirm new value → Update prompt → Regenerate |
|
||||||
|
|
||||||
|
Text correction policy:
|
||||||
|
|
||||||
|
- If the title/subtitle is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
|
||||||
|
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
|
||||||
|
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
|
||||||
|
|
||||||
## Composition Principles
|
## Composition Principles
|
||||||
|
|
||||||
- **Whitespace**: 40-60% breathing room
|
- **Whitespace**: 40-60% breathing room
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: baoyu-diagram
|
name: baoyu-diagram
|
||||||
description: Create professional, dark-themed SVG diagrams of any type — architecture diagrams, flowcharts, sequence diagrams, structural diagrams, mind maps, timelines, illustrative/conceptual diagrams, and more. Use this skill whenever the user asks for any kind of technical or conceptual diagram, visualization of a system, process flow, data flow, component relationship, network topology, decision tree, org chart, state machine, or any visual representation of structure/logic/process. Also trigger when the user says "画个图" "画一个架构图" "diagram" "flowchart" "sequence diagram" "draw me a ..." or uploads content and asks to visualize it. Output is always a standalone .svg file.
|
description: Create professional, dark-themed SVG diagrams of any type — architecture diagrams, flowcharts, sequence diagrams, structural diagrams, mind maps, timelines, illustrative/conceptual diagrams, and more. Use this skill whenever the user asks for any kind of technical or conceptual diagram, visualization of a system, process flow, data flow, component relationship, network topology, decision tree, org chart, state machine, or any visual representation of structure/logic/process. Also trigger when the user says "画个图" "画一个架构图" "diagram" "flowchart" "sequence diagram" "draw me a ..." or uploads content and asks to visualize it. Output is always a standalone .svg file.
|
||||||
|
version: 1.117.3
|
||||||
---
|
---
|
||||||
|
|
||||||
# Diagram Generator
|
# Diagram Generator
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: baoyu-image-cards
|
name: baoyu-image-cards
|
||||||
description: Generates infographic image card series with 12 visual styles, 8 layouts, and 3 color palettes. Breaks content into 1-10 cartoon-style image cards optimized for social media engagement. Use when user mentions "小红书图片", "小红书种草", "小绿书", "微信图文", "微信贴图", "image cards", "图片卡片", or wants social media infographic series.
|
description: Generates infographic image card series with 12 visual styles, 8 layouts, and 3 color palettes. Breaks content into 1-10 cartoon-style image cards optimized for social media engagement. Use when user mentions "小红书图片", "小红书种草", "小绿书", "微信图文", "微信贴图", "image cards", "图片卡片", or wants social media infographic series.
|
||||||
version: 1.56.2
|
version: 1.57.0
|
||||||
metadata:
|
metadata:
|
||||||
openclaw:
|
openclaw:
|
||||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-image-cards
|
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-image-cards
|
||||||
@@ -36,12 +36,31 @@ When this skill needs to render an image, resolve the backend in this order:
|
|||||||
|
|
||||||
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
|
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
|
||||||
|
|
||||||
|
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace titles, body copy, tags, or any other text inside an already generated image card. If text is wrong or unclear, regenerate from a corrected prompt, switch to a layout with less on-card text, or ask the user which imperfect candidate to keep.
|
||||||
|
|
||||||
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
|
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
|
||||||
|
|
||||||
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The file is the reproducibility record and lets you switch backends without regenerating prompts.
|
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The file is the reproducibility record and lets you switch backends without regenerating prompts.
|
||||||
|
|
||||||
Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) above are examples — substitute the local equivalents under the same rule.
|
Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) above are examples — substitute the local equivalents under the same rule.
|
||||||
|
|
||||||
|
## Batch Generation Policy
|
||||||
|
|
||||||
|
After every prompt file for the current generation group has been saved and verified, generate images in batches by default.
|
||||||
|
|
||||||
|
Priority order:
|
||||||
|
|
||||||
|
1. Use the chosen backend's native batch / multi-task interface if it exists. Each task must keep its own prompt file, output path, aspect ratio, session ID, and direct reference images.
|
||||||
|
2. If no native batch interface exists but the runtime can issue parallel tool calls, dispatch up to `generation_batch_size` images at a time. Default: `4`. An explicit user request in the current message, such as `--batch-size 4` or "并行4张一起生成", overrides EXTEND.md.
|
||||||
|
3. If neither native batch nor parallel tool calls are available, generate sequentially.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Honor the image-1 anchor chain: generate image 1 first, then batch images 2+ using image 1 as the reference.
|
||||||
|
- Never start a batch until every selected prompt file for that batch exists on disk.
|
||||||
|
- Retry failed items once without regenerating successful items.
|
||||||
|
- Do not use subagents merely to parallelize image rendering. Use subagents only for separate prompt iteration or creative exploration.
|
||||||
|
|
||||||
## Confirmation Policy
|
## Confirmation Policy
|
||||||
|
|
||||||
Default behavior: **confirm before generation**.
|
Default behavior: **confirm before generation**.
|
||||||
@@ -64,6 +83,7 @@ Respond in the user's language across questions, progress, errors, and completio
|
|||||||
| `--palette <name>` | Color override: macaron / warm / neon |
|
| `--palette <name>` | Color override: macaron / warm / neon |
|
||||||
| `--preset <name>` | Style + layout + optional palette shorthand (see Presets below; per-preset prompt fragments in `references/style-presets.md`) |
|
| `--preset <name>` | Style + layout + optional palette shorthand (see Presets below; per-preset prompt fragments in `references/style-presets.md`) |
|
||||||
| `--ref <files...>` | Reference images applied to image 1 as the series anchor |
|
| `--ref <files...>` | Reference images applied to image 1 as the series anchor |
|
||||||
|
| `--batch-size <n>` | Temporary generation batch size for this run. Default: `generation_batch_size` from EXTEND.md, otherwise 4. Clamp to 1-8. |
|
||||||
| `--yes` | Non-interactive: skip all confirmations, use EXTEND.md or built-in defaults, auto-confirm recommended plan (Path A) |
|
| `--yes` | Non-interactive: skip all confirmations, use EXTEND.md or built-in defaults, auto-confirm recommended plan (Path A) |
|
||||||
|
|
||||||
## Dimensions
|
## Dimensions
|
||||||
@@ -299,7 +319,7 @@ Check these paths in order; first hit wins:
|
|||||||
- **Not found + interactive** → run first-time setup (see `references/config/first-time-setup.md`) and save before anything else. Do NOT analyze content or ask style questions until preferences exist — this keeps first-run behavior predictable.
|
- **Not found + interactive** → run first-time setup (see `references/config/first-time-setup.md`) and save before anything else. Do NOT analyze content or ask style questions until preferences exist — this keeps first-run behavior predictable.
|
||||||
- **Not found + `--yes`** → skip setup, use built-in defaults (no watermark, style/layout auto-selected, language from content). Do not prompt, do not create EXTEND.md.
|
- **Not found + `--yes`** → skip setup, use built-in defaults (no watermark, style/layout auto-selected, language from content). Do not prompt, do not create EXTEND.md.
|
||||||
|
|
||||||
**EXTEND.md keys**: watermark, preferred style/layout, custom style definitions, language preference. Schema: `references/config/preferences-schema.md`.
|
**EXTEND.md keys**: watermark, preferred style/layout, custom style definitions, language preference, preferred image backend, generation batch size. Schema: `references/config/preferences-schema.md`.
|
||||||
|
|
||||||
### Step 1: Analyze Content → `analysis.md`
|
### Step 1: Analyze Content → `analysis.md`
|
||||||
|
|
||||||
@@ -348,14 +368,13 @@ With confirmed outline + style + layout + palette:
|
|||||||
|
|
||||||
**Visual consistency — image-1 anchor chain**: character / mascot / color rendering drifts between calls unless you anchor them. Generate image 1 (cover) first WITHOUT `--ref`, then pass image 1 as `--ref` to every subsequent image. This is the single most important consistency trick for this skill — don't skip it even if the backend also supports a session ID.
|
**Visual consistency — image-1 anchor chain**: character / mascot / color rendering drifts between calls unless you anchor them. Generate image 1 (cover) first WITHOUT `--ref`, then pass image 1 as `--ref` to every subsequent image. This is the single most important consistency trick for this skill — don't skip it even if the backend also supports a session ID.
|
||||||
|
|
||||||
For each image (cover, content, ending):
|
Generation flow:
|
||||||
|
|
||||||
1. Write the full prompt to `prompts/NN-{type}-{slug}.md` in the user's preferred language (backup rule applies).
|
1. Write the full prompt for every image to `prompts/NN-{type}-{slug}.md` in the user's preferred language (backup rule applies), then verify all selected prompt files exist.
|
||||||
2. Generate:
|
2. Generate **image 1** first without `--ref`; backup rule applies to the PNG file. This establishes the anchor.
|
||||||
- **Image 1**: no `--ref` (establishes the anchor).
|
3. Build a task list for **images 2+** using image 1 as `--ref <path-to-image-01.png>`.
|
||||||
- **Images 2+**: add `--ref <path-to-image-01.png>`.
|
4. Dispatch images 2+ in batches per the `## Batch Generation Policy`: backend native batch first, runtime parallel tool calls second, sequential only as fallback.
|
||||||
- Backup rule applies to the PNG files.
|
5. Report progress after each completed image. On failure, retry only the failed item once from the same saved prompt file.
|
||||||
3. Report progress after each image.
|
|
||||||
|
|
||||||
**Watermark** (if enabled in EXTEND.md): append to the generation prompt:
|
**Watermark** (if enabled in EXTEND.md): append to the generation prompt:
|
||||||
|
|
||||||
@@ -414,6 +433,12 @@ For the style × layout compatibility matrix, see the **Style × Layout Matrix**
|
|||||||
|
|
||||||
Always update the prompt file before regenerating — it's the source of truth and makes changes reproducible.
|
Always update the prompt file before regenerating — it's the source of truth and makes changes reproducible.
|
||||||
|
|
||||||
|
Text correction policy:
|
||||||
|
|
||||||
|
- If a card's title, body copy, tags, or any other rendered text is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
|
||||||
|
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
|
||||||
|
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
| File | Content |
|
| File | Content |
|
||||||
@@ -450,5 +475,6 @@ EXTEND.md lives at the first matching path listed in Step 0. Three ways to chang
|
|||||||
- `preferred_image_backend: codex-imagegen` — pin to Codex's built-in.
|
- `preferred_image_backend: codex-imagegen` — pin to Codex's built-in.
|
||||||
- `preferred_image_backend: baoyu-imagine` — pin to the baoyu-imagine skill.
|
- `preferred_image_backend: baoyu-imagine` — pin to the baoyu-imagine skill.
|
||||||
- `preferred_image_backend: ask` — confirm backend every run.
|
- `preferred_image_backend: ask` — confirm backend every run.
|
||||||
|
- `generation_batch_size: 4` — default number of images to render concurrently when the backend/runtime supports batch or parallel generation.
|
||||||
- `preferred_style: notion`, `preferred_layout: dense`, `preferred_palette: macaron`, `language: zh`.
|
- `preferred_style: notion`, `preferred_layout: dense`, `preferred_palette: macaron`, `language: zh`.
|
||||||
- `watermark.enabled: true` + `watermark.content: "@handle"` — add a watermark.
|
- `watermark.enabled: true` + `watermark.content: "@handle"` — add a watermark.
|
||||||
|
|||||||
@@ -111,12 +111,15 @@ preferred_style:
|
|||||||
preferred_layout: null
|
preferred_layout: null
|
||||||
language: null
|
language: null
|
||||||
preferred_image_backend: auto
|
preferred_image_backend: auto
|
||||||
|
generation_batch_size: 4
|
||||||
custom_styles: []
|
custom_styles: []
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
`preferred_image_backend: auto` is the baked-in default — first-time setup does not ask about it. The `## Image Generation Tools` rule in SKILL.md then picks the runtime-native tool (Codex `imagegen`, Hermes `image_generate`, etc.) when available, and falls back to installed backends.
|
`preferred_image_backend: auto` is the baked-in default — first-time setup does not ask about it. The `## Image Generation Tools` rule in SKILL.md then picks the runtime-native tool (Codex `imagegen`, Hermes `image_generate`, etc.) when available, and falls back to installed backends.
|
||||||
|
|
||||||
|
`generation_batch_size: 4` is the baked-in default for batch rendering. The current user request may override it for one run.
|
||||||
|
|
||||||
## Modifying Preferences Later
|
## Modifying Preferences Later
|
||||||
|
|
||||||
See the `## Changing Preferences` section in `SKILL.md` for the canonical list of common edits (pin backend, change defaults, retrigger setup). Full schema: `preferences-schema.md`.
|
See the `## Changing Preferences` section in `SKILL.md` for the canonical list of common edits (pin backend, change defaults, retrigger setup). Full schema: `preferences-schema.md`.
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ language: null # zh|en|ja|ko|auto
|
|||||||
|
|
||||||
preferred_image_backend: auto # auto|ask|<backend-id>
|
preferred_image_backend: auto # auto|ask|<backend-id>
|
||||||
|
|
||||||
|
generation_batch_size: 4 # 1-8, used when backend/runtime supports batch or parallel generation
|
||||||
|
|
||||||
custom_styles:
|
custom_styles:
|
||||||
- name: my-style
|
- name: my-style
|
||||||
description: "Style description"
|
description: "Style description"
|
||||||
@@ -52,6 +54,7 @@ custom_styles:
|
|||||||
| `preferred_layout` | string | null | Layout preference or null |
|
| `preferred_layout` | string | null | Layout preference or null |
|
||||||
| `language` | string | null | Output language (null = auto-detect) |
|
| `language` | string | null | Output language (null = auto-detect) |
|
||||||
| `preferred_image_backend` | string | `auto` | Image backend selection. `auto` = prefer runtime-native tool, fall back to the only installed backend, ask if multiple non-native are present. `ask` = always confirm on every run. `<backend-id>` (e.g., `codex-imagegen`, `baoyu-imagine`, `image_generate`) = pin this backend when available; fall back to `auto` when it isn't. Absent = `auto`. Resolution logic is documented in `SKILL.md`'s `## Image Generation Tools` section. |
|
| `preferred_image_backend` | string | `auto` | Image backend selection. `auto` = prefer runtime-native tool, fall back to the only installed backend, ask if multiple non-native are present. `ask` = always confirm on every run. `<backend-id>` (e.g., `codex-imagegen`, `baoyu-imagine`, `image_generate`) = pin this backend when available; fall back to `auto` when it isn't. Absent = `auto`. Resolution logic is documented in `SKILL.md`'s `## Image Generation Tools` section. |
|
||||||
|
| `generation_batch_size` | int | 4 | Number of images to dispatch per batch when the backend has native batch support or the runtime can issue parallel generation calls. Clamp invalid values to 1-8. Current user request overrides this value. |
|
||||||
| `custom_styles` | array | [] | User-defined styles |
|
| `custom_styles` | array | [] | User-defined styles |
|
||||||
|
|
||||||
## Position Options
|
## Position Options
|
||||||
@@ -109,6 +112,8 @@ language: zh
|
|||||||
|
|
||||||
preferred_image_backend: codex-imagegen
|
preferred_image_backend: codex-imagegen
|
||||||
|
|
||||||
|
generation_batch_size: 4
|
||||||
|
|
||||||
custom_styles:
|
custom_styles:
|
||||||
- name: corporate
|
- name: corporate
|
||||||
description: "Professional B2B style"
|
description: "Professional B2B style"
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ When this skill needs to render an image, resolve the backend in this order:
|
|||||||
|
|
||||||
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
|
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
|
||||||
|
|
||||||
|
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace labels, headings, callouts, data values, or any other text inside an already generated infographic. If text is wrong or unclear, regenerate from a corrected prompt, switch to a layout with less on-image text, or ask the user which imperfect candidate to keep.
|
||||||
|
|
||||||
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
|
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
|
||||||
|
|
||||||
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
|
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
|
||||||
@@ -297,6 +299,12 @@ Combine:
|
|||||||
4. Call the chosen backend with the prompt file and output path
|
4. Call the chosen backend with the prompt file and output path
|
||||||
5. On failure, auto-retry once
|
5. On failure, auto-retry once
|
||||||
|
|
||||||
|
Text correction policy:
|
||||||
|
|
||||||
|
- If labels, headings, callouts, data values, or any other rendered text is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
|
||||||
|
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
|
||||||
|
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
|
||||||
|
|
||||||
### Step 7: Output Summary
|
### Step 7: Output Summary
|
||||||
|
|
||||||
Report: topic, layout, style, aspect, language, image backend, output path, files created.
|
Report: topic, layout, style, aspect, language, image backend, output path, files created.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: baoyu-post-to-wechat
|
name: baoyu-post-to-wechat
|
||||||
description: Posts content to WeChat Official Account (微信公众号) via API or Chrome CDP. Supports article posting (文章) with HTML, markdown, or plain text input, and image-text posting (贴图, formerly 图文) with multiple images. Markdown article workflows default to converting ordinary external links into bottom citations for WeChat-friendly output. 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. Markdown article workflows default to converting ordinary external links into bottom citations for WeChat-friendly output. Use when user mentions "发布公众号", "post to wechat", "微信公众号", or "贴图/图文/文章".
|
||||||
version: 1.56.1
|
version: 1.117.3
|
||||||
metadata:
|
metadata:
|
||||||
openclaw:
|
openclaw:
|
||||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-post-to-wechat
|
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-post-to-wechat
|
||||||
@@ -64,13 +64,26 @@ Found → read, parse, apply. Not found → run first-time setup (`references/co
|
|||||||
```md
|
```md
|
||||||
default_theme: default
|
default_theme: default
|
||||||
default_color: blue
|
default_color: blue
|
||||||
default_publish_method: api
|
default_publish_method: browser
|
||||||
default_author: 宝玉
|
default_author: 宝玉
|
||||||
need_open_comment: 1
|
need_open_comment: 1
|
||||||
only_fans_can_comment: 0
|
only_fans_can_comment: 0
|
||||||
chrome_profile_path: /path/to/chrome/profile
|
chrome_profile_path: /path/to/chrome/profile
|
||||||
|
|
||||||
|
# Remote API publishing (optional) — only set if WeChat's IP allowlist
|
||||||
|
# excludes your local machine. See "Remote API Method" below.
|
||||||
|
# remote_publish_host: server.example.com
|
||||||
|
# remote_publish_user: deploy
|
||||||
|
# remote_publish_port: 22
|
||||||
|
# remote_publish_identity_file: ~/.ssh/id_ed25519
|
||||||
|
# remote_publish_known_hosts_file: ~/.ssh/known_hosts
|
||||||
|
# remote_publish_strict_host_key_checking: accept-new
|
||||||
|
# remote_publish_connect_timeout: 10
|
||||||
|
# remote_publish_proxy_jump: bastion.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Raw `ssh` / `scp` options are intentionally not supported; only the typed keys above are honored. Authentication is SSH key only (no passwords).
|
||||||
|
|
||||||
**Theme options**: default, grace, simple, modern. **Color presets**: blue, green, vermilion, yellow, purple, sky, rose, olive, black, gray, pink, red, orange (or hex).
|
**Theme options**: default, grace, simple, modern. **Color presets**: blue, green, vermilion, yellow, purple, sky, rose, olive, black, gray, pink, red, orange (or hex).
|
||||||
|
|
||||||
**Value priority**: CLI args → frontmatter → EXTEND.md (account-level → global) → skill defaults.
|
**Value priority**: CLI args → frontmatter → EXTEND.md (account-level → global) → skill defaults.
|
||||||
@@ -148,11 +161,14 @@ Ask method unless specified in EXTEND.md or CLI:
|
|||||||
|
|
||||||
| Method | Speed | Requires |
|
| Method | Speed | Requires |
|
||||||
|--------|-------|----------|
|
|--------|-------|----------|
|
||||||
| `api` (Recommended) | Fast | API credentials |
|
| `api` (Recommended) | Fast | API credentials (local IP allowlisted) |
|
||||||
| `browser` | Slow | Chrome + logged-in session |
|
| `browser` | Slow | Chrome + logged-in session |
|
||||||
|
| `remote-api` | Fast | API credentials + an SSH-reachable server whose IP is on the WeChat allowlist |
|
||||||
|
|
||||||
**API selected + missing credentials** → run guided setup per `references/api-setup.md` (writes to `.baoyu-skills/.env`).
|
**API selected + missing credentials** → run guided setup per `references/api-setup.md` (writes to `.baoyu-skills/.env`).
|
||||||
|
|
||||||
|
**`remote-api` method**: WeChat's "公众号设置 → IP 白名单" often limits API access to one or two fixed IPs. If your local machine's IP is not on that list but a cloud server's is, use `remote-api`: all markdown rendering, image processing, draft assembly, and HTML rewriting still happen locally, and only the outbound HTTPS calls to `api.weixin.qq.com` (token, uploadimg, add_material, draft/add) are tunneled through an SSH SOCKS5 dynamic port forward (`ssh -N -D`) so that WeChat sees the remote server as the source IP. No files are written to the remote host; `AppSecret` never leaves the local process. Requires only `sshd` and outbound network on the remote host — no Python, no agent process. See "Remote API Method" below.
|
||||||
|
|
||||||
### Step 3: Resolve Theme/Color and Validate Metadata
|
### Step 3: Resolve Theme/Color and Validate Metadata
|
||||||
|
|
||||||
1. **Theme**: CLI `--theme` → EXTEND.md `default_theme` → `default` (first match wins; do NOT ask if resolved).
|
1. **Theme**: CLI `--theme` → EXTEND.md `default_theme` → `default` (first match wins; do NOT ask if resolved).
|
||||||
@@ -183,6 +199,14 @@ ${BUN_X} {baseDir}/scripts/wechat-api.ts <file> --theme <theme> [--color <color>
|
|||||||
|
|
||||||
Always pass `--theme` even if it's `default`. Only pass `--color` when explicitly set by the user or EXTEND.md.
|
Always pass `--theme` even if it's `default`. Only pass `--color` when explicitly set by the user or EXTEND.md.
|
||||||
|
|
||||||
|
**Remote API method** (same script, adds `--remote`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
${BUN_X} {baseDir}/scripts/wechat-api.ts <file> --theme <theme> --remote [--remote-host <host>] [--remote-user <user>] [--remote-port <port>] [--remote-identity-file <path>] [--remote-known-hosts-file <path>] [--remote-strict-host-key-checking yes|no|accept-new] [--remote-connect-timeout <s>] [--remote-proxy-jump <spec>]
|
||||||
|
```
|
||||||
|
|
||||||
|
Any `--remote-*` flag implies `--remote`. CLI values override account-level then global `remote_publish_*` keys from EXTEND.md. Setting `default_publish_method: remote-api` also enables remote mode without `--remote`.
|
||||||
|
|
||||||
**`draft/add` payload rules**:
|
**`draft/add` payload rules**:
|
||||||
- Endpoint: `POST https://api.weixin.qq.com/cgi-bin/draft/add?access_token=ACCESS_TOKEN`
|
- Endpoint: `POST https://api.weixin.qq.com/cgi-bin/draft/add?access_token=ACCESS_TOKEN`
|
||||||
- `article_type`: `news` (default) or `newspic`
|
- `article_type`: `news` (default) or `newspic`
|
||||||
@@ -225,19 +249,20 @@ Files created:
|
|||||||
|
|
||||||
## Feature Comparison
|
## Feature Comparison
|
||||||
|
|
||||||
| Feature | Image-Text | Article (API) | Article (Browser) |
|
| Feature | Image-Text | Article (API) | Article (Remote API) | Article (Browser) |
|
||||||
|---------|:---:|:---:|:---:|
|
|---------|:---:|:---:|:---:|:---:|
|
||||||
| Plain text input | ✗ | ✓ | ✓ |
|
| Plain text input | ✗ | ✓ | ✓ | ✓ |
|
||||||
| HTML input | ✗ | ✓ | ✓ |
|
| HTML input | ✗ | ✓ | ✓ | ✓ |
|
||||||
| Markdown input | Title/content | ✓ | ✓ |
|
| Markdown input | Title/content | ✓ | ✓ | ✓ |
|
||||||
| Multiple images | ✓ (up to 9) | ✓ (inline) | ✓ (inline) |
|
| Multiple images | ✓ (up to 9) | ✓ (inline) | ✓ (inline) | ✓ (inline) |
|
||||||
| Themes | ✗ | ✓ | ✓ |
|
| Themes | ✗ | ✓ | ✓ | ✓ |
|
||||||
| Auto-generate metadata | ✗ | ✓ | ✓ |
|
| Auto-generate metadata | ✗ | ✓ | ✓ | ✓ |
|
||||||
| Default cover fallback (`imgs/cover.png`) | ✗ | ✓ | ✗ |
|
| Default cover fallback (`imgs/cover.png`) | ✗ | ✓ | ✓ | ✗ |
|
||||||
| Comment control | ✗ | ✓ | ✗ |
|
| Comment control | ✗ | ✓ | ✓ | ✗ |
|
||||||
| Requires Chrome | ✓ | ✗ | ✓ |
|
| Requires Chrome | ✓ | ✗ | ✗ | ✓ |
|
||||||
| Requires API credentials | ✗ | ✓ | ✗ |
|
| Requires API credentials | ✗ | ✓ | ✓ | ✗ |
|
||||||
| Speed | Medium | Fast | Slow |
|
| Requires SSH-reachable server with allowlisted IP | ✗ | ✗ | ✓ | ✗ |
|
||||||
|
| Speed | Medium | Fast | Fast | Slow |
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
@@ -245,12 +270,16 @@ Files created:
|
|||||||
|-------|-----|
|
|-------|-----|
|
||||||
| Missing API credentials | Follow guided setup in Step 2 |
|
| Missing API credentials | Follow guided setup in Step 2 |
|
||||||
| Access token error | Verify credentials valid and not expired |
|
| Access token error | Verify credentials valid and not expired |
|
||||||
| Not logged in (browser) | First run opens browser — scan QR to log in |
|
| Not logged in (browser) | First run opens browser — scan QR to log in. Set `TELEGRAM_BOT_TOKEN` + `TELEGRAM_CHAT_ID` to receive the QR image via Telegram |
|
||||||
| Chrome not found | Set `WECHAT_BROWSER_CHROME_PATH` |
|
| Chrome not found | Set `WECHAT_BROWSER_CHROME_PATH` |
|
||||||
| Title/summary missing | Use auto-generation or provide manually |
|
| Title/summary missing | Use auto-generation or provide manually |
|
||||||
| No cover image | Add frontmatter cover or place `imgs/cover.png` in article directory |
|
| No cover image | Add frontmatter cover or place `imgs/cover.png` in article directory |
|
||||||
| Wrong comment defaults | Check `need_open_comment` / `only_fans_can_comment` in EXTEND.md |
|
| Wrong comment defaults | Check `need_open_comment` / `only_fans_can_comment` in EXTEND.md |
|
||||||
| Paste fails | Check system clipboard permissions |
|
| Paste fails | Check system clipboard permissions |
|
||||||
|
| `Remote publish host is required` | Set `--remote-host` or `remote_publish_host` in EXTEND.md |
|
||||||
|
| `SOCKS proxy on 127.0.0.1:… not ready` | SSH could not start the tunnel — check key, host, `StrictHostKeyChecking`, or use `--remote-connect-timeout` |
|
||||||
|
| `ssh exited early` during remote publish | Verify the user can `ssh` non-interactively to the server; raise `--remote-connect-timeout` if the link is slow |
|
||||||
|
| Remote API call returns `errcode 40164` (invalid IP) | The remote server's egress IP is not on WeChat's allowlist; add it in 公众号设置 → IP 白名单 |
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
|
|||||||
@@ -86,8 +86,12 @@ options:
|
|||||||
description: "Fast, requires API credentials (AppID + AppSecret)"
|
description: "Fast, requires API credentials (AppID + AppSecret)"
|
||||||
- label: "browser"
|
- label: "browser"
|
||||||
description: "Slow, requires Chrome and login session"
|
description: "Slow, requires Chrome and login session"
|
||||||
|
- label: "remote-api"
|
||||||
|
description: "Fast, tunnels WeChat API calls through SSH to a server whose IP is on the WeChat allowlist"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If the user selects `remote-api`, prompt for `remote_publish_host` and (optionally) `remote_publish_user`, `remote_publish_identity_file`. These can also be filled in later by editing EXTEND.md.
|
||||||
|
|
||||||
### Question 4: Default Author
|
### Question 4: Default Author
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -157,13 +161,26 @@ options:
|
|||||||
```md
|
```md
|
||||||
default_theme: [default/grace/simple/modern]
|
default_theme: [default/grace/simple/modern]
|
||||||
default_color: [preset name, hex, or empty for theme default]
|
default_color: [preset name, hex, or empty for theme default]
|
||||||
default_publish_method: [api/browser]
|
default_publish_method: [api/browser/remote-api]
|
||||||
default_author: [author name or empty]
|
default_author: [author name or empty]
|
||||||
need_open_comment: [1/0]
|
need_open_comment: [1/0]
|
||||||
only_fans_can_comment: [1/0]
|
only_fans_can_comment: [1/0]
|
||||||
chrome_profile_path:
|
chrome_profile_path:
|
||||||
|
|
||||||
|
# Remote API publishing — only fill in if default_publish_method is remote-api
|
||||||
|
# or you plan to pass --remote on the CLI.
|
||||||
|
remote_publish_host:
|
||||||
|
remote_publish_user:
|
||||||
|
remote_publish_port:
|
||||||
|
remote_publish_identity_file:
|
||||||
|
remote_publish_known_hosts_file:
|
||||||
|
remote_publish_strict_host_key_checking:
|
||||||
|
remote_publish_connect_timeout:
|
||||||
|
remote_publish_proxy_jump:
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Raw `ssh` / `scp` options are intentionally not supported; only the typed keys above are honored. Authentication is SSH key only.
|
||||||
|
|
||||||
### Multi-Account
|
### Multi-Account
|
||||||
|
|
||||||
```md
|
```md
|
||||||
@@ -174,15 +191,19 @@ accounts:
|
|||||||
- name: [display name]
|
- name: [display name]
|
||||||
alias: [short key, e.g. "baoyu"]
|
alias: [short key, e.g. "baoyu"]
|
||||||
default: true
|
default: true
|
||||||
default_publish_method: [api/browser]
|
default_publish_method: [api/browser/remote-api]
|
||||||
default_author: [author name]
|
default_author: [author name]
|
||||||
need_open_comment: [1/0]
|
need_open_comment: [1/0]
|
||||||
only_fans_can_comment: [1/0]
|
only_fans_can_comment: [1/0]
|
||||||
app_id: [WeChat App ID, optional]
|
app_id: [WeChat App ID, optional]
|
||||||
app_secret: [WeChat App Secret, optional]
|
app_secret: [WeChat App Secret, optional]
|
||||||
|
# Remote API publishing (optional, per-account override of globals)
|
||||||
|
remote_publish_host:
|
||||||
|
remote_publish_user:
|
||||||
|
remote_publish_identity_file:
|
||||||
- name: [second account name]
|
- name: [second account name]
|
||||||
alias: [short key, e.g. "ai-tools"]
|
alias: [short key, e.g. "ai-tools"]
|
||||||
default_publish_method: [api/browser]
|
default_publish_method: [api/browser/remote-api]
|
||||||
default_author: [author name]
|
default_author: [author name]
|
||||||
need_open_comment: [1/0]
|
need_open_comment: [1/0]
|
||||||
only_fans_can_comment: [1/0]
|
only_fans_can_comment: [1/0]
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ accounts:
|
|||||||
|
|
||||||
## Per-Account vs Global Keys
|
## Per-Account vs Global Keys
|
||||||
|
|
||||||
**Per-account** (also accepted globally as fallback): `default_publish_method`, `default_author`, `need_open_comment`, `only_fans_can_comment`, `app_id`, `app_secret`, `chrome_profile_path`.
|
**Per-account** (also accepted globally as fallback): `default_publish_method`, `default_author`, `need_open_comment`, `only_fans_can_comment`, `app_id`, `app_secret`, `chrome_profile_path`, `remote_publish_host`, `remote_publish_user`, `remote_publish_port`, `remote_publish_identity_file`, `remote_publish_known_hosts_file`, `remote_publish_strict_host_key_checking`, `remote_publish_connect_timeout`, `remote_publish_proxy_jump`.
|
||||||
|
|
||||||
**Global-only** (always shared): `default_theme`, `default_color`.
|
**Global-only** (always shared): `default_theme`, `default_color`.
|
||||||
|
|
||||||
@@ -99,3 +99,54 @@ ${BUN_X} {baseDir}/scripts/wechat-api.ts <file> --theme default --account ai-too
|
|||||||
${BUN_X} {baseDir}/scripts/wechat-article.ts --markdown <file> --theme default --account baoyu
|
${BUN_X} {baseDir}/scripts/wechat-article.ts --markdown <file> --theme default --account baoyu
|
||||||
${BUN_X} {baseDir}/scripts/wechat-browser.ts --markdown <file> --images ./photos/ --account baoyu
|
${BUN_X} {baseDir}/scripts/wechat-browser.ts --markdown <file> --images ./photos/ --account baoyu
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Remote API Publishing
|
||||||
|
|
||||||
|
`wechat-api.ts` supports a `remote-api` mode that tunnels WeChat API calls through an SSH SOCKS5 dynamic port forward to a server whose IP is on WeChat's allowlist. Markdown rendering, image processing, draft assembly, and HTML rewriting still happen locally; only outbound HTTPS calls to `api.weixin.qq.com` traverse the tunnel. No files are written to the remote host and `AppSecret` never leaves the local process. The remote host needs only `sshd` and outbound network access.
|
||||||
|
|
||||||
|
### Per-Account Configuration
|
||||||
|
|
||||||
|
```md
|
||||||
|
default_theme: default
|
||||||
|
default_color: blue
|
||||||
|
default_publish_method: browser # browser remains the default
|
||||||
|
|
||||||
|
accounts:
|
||||||
|
- name: 宝玉的技术分享
|
||||||
|
alias: baoyu
|
||||||
|
default: true
|
||||||
|
default_publish_method: api
|
||||||
|
default_author: 宝玉
|
||||||
|
app_id: your_wechat_app_id
|
||||||
|
app_secret: your_wechat_app_secret
|
||||||
|
- name: AI工具集
|
||||||
|
alias: ai-tools
|
||||||
|
default_publish_method: remote-api
|
||||||
|
default_author: AI工具集
|
||||||
|
app_id: your_ai_tools_app_id
|
||||||
|
app_secret: your_ai_tools_app_secret
|
||||||
|
remote_publish_host: ai-tools-server.example.com
|
||||||
|
remote_publish_user: deploy
|
||||||
|
remote_publish_port: 22
|
||||||
|
remote_publish_identity_file: /home/me/.ssh/id_ed25519
|
||||||
|
remote_publish_known_hosts_file: /home/me/.ssh/known_hosts
|
||||||
|
remote_publish_strict_host_key_checking: accept-new
|
||||||
|
```
|
||||||
|
|
||||||
|
Account-level `remote_publish_*` values override top-level globals. CLI `--remote-*` flags override both.
|
||||||
|
|
||||||
|
### CLI Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use the account's default_publish_method (remote-api here):
|
||||||
|
${BUN_X} {baseDir}/scripts/wechat-api.ts <file> --theme default --account ai-tools
|
||||||
|
|
||||||
|
# Force remote mode regardless of default_publish_method:
|
||||||
|
${BUN_X} {baseDir}/scripts/wechat-api.ts <file> --theme default --account baoyu --remote --remote-host other-server.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Notes
|
||||||
|
|
||||||
|
- Authentication is SSH key only. Passwords and `ssh-askpass` are not used.
|
||||||
|
- Only the typed `remote_publish_*` keys are read; raw `ssh` / `scp` options are intentionally not supported.
|
||||||
|
- The tunnel forwards raw TCP; TLS verification for `api.weixin.qq.com` is still performed end-to-end by the local process.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"baoyu-chrome-cdp": "^0.1.0",
|
"baoyu-chrome-cdp": "^0.1.0",
|
||||||
"baoyu-md": "^0.1.0",
|
"baoyu-md": "^0.1.0",
|
||||||
"jimp": "^1.6.0",
|
"jimp": "^1.6.0",
|
||||||
|
"socks": "^2.8.9",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -165,6 +166,8 @@
|
|||||||
|
|
||||||
"image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="],
|
"image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="],
|
||||||
|
|
||||||
|
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
|
||||||
|
|
||||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||||
|
|
||||||
"jimp": ["jimp@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/diff": "1.6.1", "@jimp/js-bmp": "1.6.1", "@jimp/js-gif": "1.6.1", "@jimp/js-jpeg": "1.6.1", "@jimp/js-png": "1.6.1", "@jimp/js-tiff": "1.6.1", "@jimp/plugin-blit": "1.6.1", "@jimp/plugin-blur": "1.6.1", "@jimp/plugin-circle": "1.6.1", "@jimp/plugin-color": "1.6.1", "@jimp/plugin-contain": "1.6.1", "@jimp/plugin-cover": "1.6.1", "@jimp/plugin-crop": "1.6.1", "@jimp/plugin-displace": "1.6.1", "@jimp/plugin-dither": "1.6.1", "@jimp/plugin-fisheye": "1.6.1", "@jimp/plugin-flip": "1.6.1", "@jimp/plugin-hash": "1.6.1", "@jimp/plugin-mask": "1.6.1", "@jimp/plugin-print": "1.6.1", "@jimp/plugin-quantize": "1.6.1", "@jimp/plugin-resize": "1.6.1", "@jimp/plugin-rotate": "1.6.1", "@jimp/plugin-threshold": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1" } }, "sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw=="],
|
"jimp": ["jimp@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/diff": "1.6.1", "@jimp/js-bmp": "1.6.1", "@jimp/js-gif": "1.6.1", "@jimp/js-jpeg": "1.6.1", "@jimp/js-png": "1.6.1", "@jimp/js-tiff": "1.6.1", "@jimp/plugin-blit": "1.6.1", "@jimp/plugin-blur": "1.6.1", "@jimp/plugin-circle": "1.6.1", "@jimp/plugin-color": "1.6.1", "@jimp/plugin-contain": "1.6.1", "@jimp/plugin-cover": "1.6.1", "@jimp/plugin-crop": "1.6.1", "@jimp/plugin-displace": "1.6.1", "@jimp/plugin-dither": "1.6.1", "@jimp/plugin-fisheye": "1.6.1", "@jimp/plugin-flip": "1.6.1", "@jimp/plugin-hash": "1.6.1", "@jimp/plugin-mask": "1.6.1", "@jimp/plugin-print": "1.6.1", "@jimp/plugin-quantize": "1.6.1", "@jimp/plugin-resize": "1.6.1", "@jimp/plugin-rotate": "1.6.1", "@jimp/plugin-threshold": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1" } }, "sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw=="],
|
||||||
@@ -277,6 +280,10 @@
|
|||||||
|
|
||||||
"slick": ["slick@1.12.2", "", {}, "sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A=="],
|
"slick": ["slick@1.12.2", "", {}, "sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A=="],
|
||||||
|
|
||||||
|
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
|
||||||
|
|
||||||
|
"socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="],
|
||||||
|
|
||||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||||
|
|
||||||
"strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
|
"strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const clipboardScript = fs.readFileSync(path.join(__dirname, "copy-to-clipboard.ts"), "utf8");
|
||||||
|
|
||||||
|
test("macOS image clipboard copy avoids Swift AppKit JIT", () => {
|
||||||
|
assert.match(clipboardScript, /copyImageMacWithOsascript/);
|
||||||
|
assert.doesNotMatch(clipboardScript, /await runCommand\('swift', \[swiftPath, 'image', imagePath\]\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("macOS image clipboard copy converts WebP to PNG before AppleScript", () => {
|
||||||
|
assert.match(clipboardScript, /convertWebpMacToPng/);
|
||||||
|
assert.match(clipboardScript, /path\.extname\(imagePath\)\.toLowerCase\(\) === '\.webp'/);
|
||||||
|
assert.match(clipboardScript, /await copyImageMacWithOsascript\(pngPath\)/);
|
||||||
|
});
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import decodeWebp, { init as initWebpDecode } from '@jsquash/webp/decode.js';
|
||||||
|
import { Jimp, JimpMime } from 'jimp';
|
||||||
|
|
||||||
const SUPPORTED_IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
|
const SUPPORTED_IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
|
||||||
|
|
||||||
@@ -186,12 +189,73 @@ default:
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyImageMac(imagePath: string): Promise<void> {
|
function escapeAppleScriptString(value: string): string {
|
||||||
await withTempDir('copy-to-clipboard-', async (tempDir) => {
|
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
const swiftPath = path.join(tempDir, 'clipboard.swift');
|
}
|
||||||
await writeFile(swiftPath, getMacSwiftClipboardSource(), 'utf8');
|
|
||||||
await runCommand('swift', [swiftPath, 'image', imagePath]);
|
function getAppleScriptImageType(imagePath: string): string {
|
||||||
|
const ext = path.extname(imagePath).toLowerCase();
|
||||||
|
switch (ext) {
|
||||||
|
case '.png':
|
||||||
|
return '«class PNGf»';
|
||||||
|
case '.jpg':
|
||||||
|
case '.jpeg':
|
||||||
|
return 'JPEG picture';
|
||||||
|
case '.gif':
|
||||||
|
return 'GIF picture';
|
||||||
|
default:
|
||||||
|
throw new Error(`macOS clipboard image copy supports PNG, JPEG, and GIF via AppleScript; convert ${ext || 'this file'} to PNG first.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let webpDecoderReady: Promise<void> | undefined;
|
||||||
|
|
||||||
|
async function ensureWebpDecoder(): Promise<void> {
|
||||||
|
if (!webpDecoderReady) {
|
||||||
|
webpDecoderReady = (async () => {
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const wasmPath = path.resolve(__dirname, 'node_modules/@jsquash/webp/codec/dec/webp_dec.wasm');
|
||||||
|
const wasmModule = await WebAssembly.compile(await readFile(wasmPath));
|
||||||
|
await initWebpDecode(wasmModule, {});
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
await webpDecoderReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertWebpMacToPng(webpPath: string, tempDir: string): Promise<string> {
|
||||||
|
await ensureWebpDecoder();
|
||||||
|
const decoded = await decodeWebp(await readFile(webpPath));
|
||||||
|
const image = new Jimp({
|
||||||
|
data: Buffer.from(decoded.data.buffer, decoded.data.byteOffset, decoded.data.byteLength),
|
||||||
|
width: decoded.width,
|
||||||
|
height: decoded.height,
|
||||||
});
|
});
|
||||||
|
const pngPath = path.join(tempDir, `${path.basename(webpPath, path.extname(webpPath))}.png`);
|
||||||
|
await writeFile(pngPath, await image.getBuffer(JimpMime.png));
|
||||||
|
return pngPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyImageMacWithOsascript(imagePath: string): Promise<void> {
|
||||||
|
const imageType = getAppleScriptImageType(imagePath);
|
||||||
|
const escapedPath = escapeAppleScriptString(imagePath);
|
||||||
|
await runCommand('osascript', [
|
||||||
|
'-e',
|
||||||
|
`set the clipboard to (read (POSIX file "${escapedPath}") as ${imageType})`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyImageMac(imagePath: string): Promise<void> {
|
||||||
|
if (path.extname(imagePath).toLowerCase() === '.webp') {
|
||||||
|
await withTempDir('copy-to-clipboard-', async (tempDir) => {
|
||||||
|
const pngPath = await convertWebpMacToPng(imagePath, tempDir);
|
||||||
|
await copyImageMacWithOsascript(pngPath);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await copyImageMacWithOsascript(imagePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyHtmlMac(htmlFilePath: string): Promise<void> {
|
async function copyHtmlMac(htmlFilePath: string): Promise<void> {
|
||||||
@@ -377,4 +441,3 @@ await main().catch((err) => {
|
|||||||
console.error(`Error: ${message}`);
|
console.error(`Error: ${message}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"@jsquash/webp": "^1.5.0",
|
"@jsquash/webp": "^1.5.0",
|
||||||
"baoyu-chrome-cdp": "^0.1.0",
|
"baoyu-chrome-cdp": "^0.1.0",
|
||||||
"baoyu-md": "^0.1.0",
|
"baoyu-md": "^0.1.0",
|
||||||
"jimp": "^1.6.0"
|
"jimp": "^1.6.0",
|
||||||
|
"socks": "^2.8.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,25 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { loadWechatExtendConfig, resolveAccount, loadCredentials } from "./wechat-extend-config.ts";
|
import {
|
||||||
|
loadWechatExtendConfig,
|
||||||
|
resolveAccount,
|
||||||
|
loadCredentials,
|
||||||
|
type ResolvedAccount,
|
||||||
|
type StrictHostKeyChecking,
|
||||||
|
} from "./wechat-extend-config.ts";
|
||||||
import {
|
import {
|
||||||
type WechatUploadAsset,
|
type WechatUploadAsset,
|
||||||
prepareWechatBodyImageUpload,
|
prepareWechatBodyImageUpload,
|
||||||
needsWechatBodyImageProcessing,
|
needsWechatBodyImageProcessing,
|
||||||
detectImageFormatFromBuffer,
|
|
||||||
} from "./wechat-image-processor.ts";
|
} from "./wechat-image-processor.ts";
|
||||||
|
import { loadUploadAsset } from "./wechat-image-loader.ts";
|
||||||
|
import { wechatHttp, buildMultipart, type WechatClient } from "./wechat-http.ts";
|
||||||
|
import {
|
||||||
|
type RemotePublishConfig,
|
||||||
|
normalizeRemoteConfig,
|
||||||
|
withSshTunnel,
|
||||||
|
} from "./wechat-remote-publish.ts";
|
||||||
|
|
||||||
interface AccessTokenResponse {
|
interface AccessTokenResponse {
|
||||||
access_token?: string;
|
access_token?: string;
|
||||||
@@ -62,13 +74,17 @@ const UPLOAD_BODY_IMG_URL = "https://api.weixin.qq.com/cgi-bin/media/uploadimg";
|
|||||||
const UPLOAD_MATERIAL_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material";
|
const UPLOAD_MATERIAL_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material";
|
||||||
const DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/add";
|
const DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/add";
|
||||||
|
|
||||||
async function fetchAccessToken(appId: string, appSecret: string): Promise<string> {
|
async function fetchAccessToken(
|
||||||
|
appId: string,
|
||||||
|
appSecret: string,
|
||||||
|
client: WechatClient = wechatHttp,
|
||||||
|
): Promise<string> {
|
||||||
const url = `${TOKEN_URL}?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;
|
const url = `${TOKEN_URL}?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;
|
||||||
const res = await fetch(url);
|
const res = await client(url);
|
||||||
if (!res.ok) {
|
if (res.status < 200 || res.status >= 300) {
|
||||||
throw new Error(`Failed to fetch access token: ${res.status}`);
|
throw new Error(`Failed to fetch access token: ${res.status}`);
|
||||||
}
|
}
|
||||||
const data = await res.json() as AccessTokenResponse;
|
const data = await res.json<AccessTokenResponse>();
|
||||||
if (data.errcode) {
|
if (data.errcode) {
|
||||||
throw new Error(`Access token error ${data.errcode}: ${data.errmsg}`);
|
throw new Error(`Access token error ${data.errcode}: ${data.errmsg}`);
|
||||||
}
|
}
|
||||||
@@ -83,86 +99,12 @@ function toHttpsUrl(url: string | undefined): string {
|
|||||||
return url.startsWith("http://") ? url.replace(/^http:\/\//i, "https://") : url;
|
return url.startsWith("http://") ? url.replace(/^http:\/\//i, "https://") : url;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadUploadAsset(
|
|
||||||
imagePath: string,
|
|
||||||
baseDir?: string,
|
|
||||||
): Promise<WechatUploadAsset> {
|
|
||||||
let fileBuffer: Buffer;
|
|
||||||
let filename: string;
|
|
||||||
let contentType: string;
|
|
||||||
let fileSize = 0;
|
|
||||||
let fileExt = "";
|
|
||||||
|
|
||||||
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
|
|
||||||
const response = await fetch(imagePath);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to download image: ${imagePath}`);
|
|
||||||
}
|
|
||||||
const buffer = await response.arrayBuffer();
|
|
||||||
if (buffer.byteLength === 0) {
|
|
||||||
throw new Error(`Remote image is empty: ${imagePath}`);
|
|
||||||
}
|
|
||||||
fileBuffer = Buffer.from(buffer);
|
|
||||||
fileSize = buffer.byteLength;
|
|
||||||
const urlPath = imagePath.split("?")[0];
|
|
||||||
filename = path.basename(urlPath) || "image.jpg";
|
|
||||||
fileExt = path.extname(filename).toLowerCase();
|
|
||||||
contentType = response.headers.get("content-type") || "image/jpeg";
|
|
||||||
} else {
|
|
||||||
const resolvedPath = path.isAbsolute(imagePath)
|
|
||||||
? imagePath
|
|
||||||
: path.resolve(baseDir || process.cwd(), imagePath);
|
|
||||||
|
|
||||||
if (!fs.existsSync(resolvedPath)) {
|
|
||||||
throw new Error(`Image not found: ${resolvedPath}`);
|
|
||||||
}
|
|
||||||
const stats = fs.statSync(resolvedPath);
|
|
||||||
if (stats.size === 0) {
|
|
||||||
throw new Error(`Local image is empty: ${resolvedPath}`);
|
|
||||||
}
|
|
||||||
fileSize = stats.size;
|
|
||||||
fileBuffer = fs.readFileSync(resolvedPath);
|
|
||||||
filename = path.basename(resolvedPath);
|
|
||||||
fileExt = path.extname(filename).toLowerCase();
|
|
||||||
const mimeTypes: Record<string, string> = {
|
|
||||||
".jpg": "image/jpeg",
|
|
||||||
".jpeg": "image/jpeg",
|
|
||||||
".png": "image/png",
|
|
||||||
".gif": "image/gif",
|
|
||||||
".webp": "image/webp",
|
|
||||||
".bmp": "image/bmp",
|
|
||||||
".tiff": "image/tiff",
|
|
||||||
".tif": "image/tiff",
|
|
||||||
".svg": "image/svg+xml",
|
|
||||||
".ico": "image/x-icon",
|
|
||||||
};
|
|
||||||
contentType = mimeTypes[fileExt] || "image/jpeg";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect actual format from magic bytes to fix extension/content-type mismatches
|
|
||||||
// (e.g. CDNs serving WebP for URLs with .png extension)
|
|
||||||
const detected = detectImageFormatFromBuffer(fileBuffer);
|
|
||||||
if (detected && detected.contentType !== contentType) {
|
|
||||||
console.error(`[wechat-api] Format mismatch: ${filename} declared as ${contentType}, actual ${detected.contentType}`);
|
|
||||||
contentType = detected.contentType;
|
|
||||||
fileExt = detected.fileExt;
|
|
||||||
filename = `${path.basename(filename, path.extname(filename))}${detected.fileExt}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
buffer: fileBuffer,
|
|
||||||
filename,
|
|
||||||
contentType,
|
|
||||||
fileExt,
|
|
||||||
fileSize,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadImage(
|
async function uploadImage(
|
||||||
imagePath: string,
|
imagePath: string,
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
baseDir?: string,
|
baseDir?: string,
|
||||||
uploadType: "body" | "material" = "body"
|
uploadType: "body" | "material" = "body",
|
||||||
|
client: WechatClient = wechatHttp,
|
||||||
): Promise<UploadResponse> {
|
): Promise<UploadResponse> {
|
||||||
const asset = await loadUploadAsset(imagePath, baseDir);
|
const asset = await loadUploadAsset(imagePath, baseDir);
|
||||||
let uploadAsset = asset;
|
let uploadAsset = asset;
|
||||||
@@ -187,6 +129,7 @@ async function uploadImage(
|
|||||||
uploadAsset.contentType,
|
uploadAsset.contentType,
|
||||||
accessToken,
|
accessToken,
|
||||||
uploadType,
|
uploadType,
|
||||||
|
client,
|
||||||
);
|
);
|
||||||
|
|
||||||
// media/uploadimg 接口只返回 URL,material/add_material 返回 media_id
|
// media/uploadimg 接口只返回 URL,material/add_material 返回 media_id
|
||||||
@@ -201,39 +144,27 @@ async function uploadImage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 实际的微信上传函数
|
|
||||||
async function uploadToWechat(
|
async function uploadToWechat(
|
||||||
fileBuffer: Buffer,
|
fileBuffer: Buffer,
|
||||||
filename: string,
|
filename: string,
|
||||||
contentType: string,
|
contentType: string,
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
uploadType: "body" | "material"
|
uploadType: "body" | "material",
|
||||||
|
client: WechatClient = wechatHttp,
|
||||||
): Promise<UploadResponse> {
|
): Promise<UploadResponse> {
|
||||||
const boundary = `----WebKitFormBoundary${Date.now().toString(16)}`;
|
const multipart = buildMultipart([
|
||||||
const header = [
|
{ name: "media", filename, contentType, data: fileBuffer },
|
||||||
`--${boundary}`,
|
]);
|
||||||
`Content-Disposition: form-data; name="media"; filename="${filename}"`,
|
|
||||||
`Content-Type: ${contentType}`,
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
].join("\r\n");
|
|
||||||
const footer = `\r\n--${boundary}--\r\n`;
|
|
||||||
|
|
||||||
const headerBuffer = Buffer.from(header, "utf-8");
|
|
||||||
const footerBuffer = Buffer.from(footer, "utf-8");
|
|
||||||
const body = Buffer.concat([headerBuffer, fileBuffer, footerBuffer]);
|
|
||||||
|
|
||||||
const uploadUrl = uploadType === "body" ? UPLOAD_BODY_IMG_URL : UPLOAD_MATERIAL_URL;
|
const uploadUrl = uploadType === "body" ? UPLOAD_BODY_IMG_URL : UPLOAD_MATERIAL_URL;
|
||||||
const url = `${uploadUrl}?type=image&access_token=${accessToken}`;
|
const url = `${uploadUrl}?type=image&access_token=${accessToken}`;
|
||||||
const res = await fetch(url, {
|
const res = await client(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": multipart.contentType },
|
||||||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
body: multipart.body,
|
||||||
},
|
|
||||||
body,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json() as UploadResponse;
|
const data = await res.json<UploadResponse>();
|
||||||
if (data.errcode && data.errcode !== 0) {
|
if (data.errcode && data.errcode !== 0) {
|
||||||
throw new Error(`Upload failed ${data.errcode}: ${data.errmsg}`);
|
throw new Error(`Upload failed ${data.errcode}: ${data.errmsg}`);
|
||||||
}
|
}
|
||||||
@@ -248,6 +179,7 @@ async function uploadImagesInHtml(
|
|||||||
contentImages: ImageInfo[] = [],
|
contentImages: ImageInfo[] = [],
|
||||||
articleType: ArticleType = "news",
|
articleType: ArticleType = "news",
|
||||||
collectNewsCoverFallback: boolean = false,
|
collectNewsCoverFallback: boolean = false,
|
||||||
|
client: WechatClient = wechatHttp,
|
||||||
): Promise<{ html: string; firstCoverMediaId: string; imageMediaIds: string[] }> {
|
): Promise<{ html: string; firstCoverMediaId: string; imageMediaIds: string[] }> {
|
||||||
const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi;
|
const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi;
|
||||||
const matches = [...html.matchAll(imgRegex)];
|
const matches = [...html.matchAll(imgRegex)];
|
||||||
@@ -268,7 +200,7 @@ async function uploadImagesInHtml(
|
|||||||
if (src.startsWith("https://mmbiz.qpic.cn")) {
|
if (src.startsWith("https://mmbiz.qpic.cn")) {
|
||||||
if (collectNewsCoverFallback && !firstCoverMediaId) {
|
if (collectNewsCoverFallback && !firstCoverMediaId) {
|
||||||
try {
|
try {
|
||||||
const coverResp = await uploadImage(src, accessToken, baseDir, "material");
|
const coverResp = await uploadImage(src, accessToken, baseDir, "material", client);
|
||||||
firstCoverMediaId = coverResp.media_id;
|
firstCoverMediaId = coverResp.media_id;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[wechat-api] Failed to reuse existing WeChat image as cover: ${src}`, err);
|
console.error(`[wechat-api] Failed to reuse existing WeChat image as cover: ${src}`, err);
|
||||||
@@ -284,8 +216,7 @@ async function uploadImagesInHtml(
|
|||||||
try {
|
try {
|
||||||
let resp = uploadedBySource.get(imagePath);
|
let resp = uploadedBySource.get(imagePath);
|
||||||
if (!resp) {
|
if (!resp) {
|
||||||
// 正文图片使用 media/uploadimg 接口获取 URL
|
resp = await uploadImage(imagePath, accessToken, baseDir, "body", client);
|
||||||
resp = await uploadImage(imagePath, accessToken, baseDir, "body");
|
|
||||||
uploadedBySource.set(imagePath, resp);
|
uploadedBySource.set(imagePath, resp);
|
||||||
}
|
}
|
||||||
const newTag = fullTag
|
const newTag = fullTag
|
||||||
@@ -296,7 +227,7 @@ async function uploadImagesInHtml(
|
|||||||
if (shouldUploadMaterial) {
|
if (shouldUploadMaterial) {
|
||||||
let materialResp = uploadedBySource.get(`${imagePath}:material`);
|
let materialResp = uploadedBySource.get(`${imagePath}:material`);
|
||||||
if (!materialResp) {
|
if (!materialResp) {
|
||||||
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material");
|
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", client);
|
||||||
uploadedBySource.set(`${imagePath}:material`, materialResp);
|
uploadedBySource.set(`${imagePath}:material`, materialResp);
|
||||||
}
|
}
|
||||||
if (articleType === "newspic" && materialResp.media_id) {
|
if (articleType === "newspic" && materialResp.media_id) {
|
||||||
@@ -320,8 +251,7 @@ async function uploadImagesInHtml(
|
|||||||
try {
|
try {
|
||||||
let resp = uploadedBySource.get(imagePath);
|
let resp = uploadedBySource.get(imagePath);
|
||||||
if (!resp) {
|
if (!resp) {
|
||||||
// 正文图片使用 media/uploadimg 接口获取 URL
|
resp = await uploadImage(imagePath, accessToken, baseDir, "body", client);
|
||||||
resp = await uploadImage(imagePath, accessToken, baseDir, "body");
|
|
||||||
uploadedBySource.set(imagePath, resp);
|
uploadedBySource.set(imagePath, resp);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +261,7 @@ async function uploadImagesInHtml(
|
|||||||
if (shouldUploadMaterial) {
|
if (shouldUploadMaterial) {
|
||||||
let materialResp = uploadedBySource.get(`${imagePath}:material`);
|
let materialResp = uploadedBySource.get(`${imagePath}:material`);
|
||||||
if (!materialResp) {
|
if (!materialResp) {
|
||||||
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material");
|
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", client);
|
||||||
uploadedBySource.set(`${imagePath}:material`, materialResp);
|
uploadedBySource.set(`${imagePath}:material`, materialResp);
|
||||||
}
|
}
|
||||||
if (articleType === "newspic" && materialResp.media_id) {
|
if (articleType === "newspic" && materialResp.media_id) {
|
||||||
@@ -351,7 +281,8 @@ async function uploadImagesInHtml(
|
|||||||
|
|
||||||
async function publishToDraft(
|
async function publishToDraft(
|
||||||
options: ArticleOptions,
|
options: ArticleOptions,
|
||||||
accessToken: string
|
accessToken: string,
|
||||||
|
client: WechatClient = wechatHttp,
|
||||||
): Promise<PublishResponse> {
|
): Promise<PublishResponse> {
|
||||||
const url = `${DRAFT_URL}?access_token=${accessToken}`;
|
const url = `${DRAFT_URL}?access_token=${accessToken}`;
|
||||||
|
|
||||||
@@ -388,15 +319,13 @@ async function publishToDraft(
|
|||||||
if (options.digest) article.digest = options.digest;
|
if (options.digest) article.digest = options.digest;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await client(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ articles: [article] }),
|
body: JSON.stringify({ articles: [article] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json() as PublishResponse;
|
const data = await res.json<PublishResponse>();
|
||||||
if (data.errcode && data.errcode !== 0) {
|
if (data.errcode && data.errcode !== 0) {
|
||||||
throw new Error(`Publish failed ${data.errcode}: ${data.errmsg}`);
|
throw new Error(`Publish failed ${data.errcode}: ${data.errmsg}`);
|
||||||
}
|
}
|
||||||
@@ -494,6 +423,15 @@ Options:
|
|||||||
--account <alias> Select account by alias (for multi-account setups)
|
--account <alias> Select account by alias (for multi-account setups)
|
||||||
--no-cite Disable bottom citations for ordinary external links in markdown mode
|
--no-cite Disable bottom citations for ordinary external links in markdown mode
|
||||||
--dry-run Parse and render only, don't publish
|
--dry-run Parse and render only, don't publish
|
||||||
|
--remote Route WeChat API calls via SSH SOCKS5 tunnel to a whitelisted server
|
||||||
|
--remote-host <h> Remote server host (implies --remote)
|
||||||
|
--remote-user <u> SSH user (default: root, implies --remote)
|
||||||
|
--remote-port <n> SSH port (default: 22, implies --remote)
|
||||||
|
--remote-identity-file <p> SSH private key path (implies --remote)
|
||||||
|
--remote-known-hosts-file <p> SSH known_hosts file path (implies --remote)
|
||||||
|
--remote-strict-host-key-checking <yes|no|accept-new> (implies --remote)
|
||||||
|
--remote-connect-timeout <seconds> SSH ConnectTimeout (implies --remote)
|
||||||
|
--remote-proxy-jump <spec> SSH ProxyJump value (implies --remote)
|
||||||
--help Show this help
|
--help Show this help
|
||||||
|
|
||||||
Frontmatter Fields (markdown):
|
Frontmatter Fields (markdown):
|
||||||
@@ -539,6 +477,15 @@ interface CliArgs {
|
|||||||
account?: string;
|
account?: string;
|
||||||
citeStatus: boolean;
|
citeStatus: boolean;
|
||||||
dryRun: boolean;
|
dryRun: boolean;
|
||||||
|
remote: boolean;
|
||||||
|
remoteHost?: string;
|
||||||
|
remoteUser?: string;
|
||||||
|
remotePort?: number;
|
||||||
|
remoteIdentityFile?: string;
|
||||||
|
remoteKnownHostsFile?: string;
|
||||||
|
remoteStrictHostKeyChecking?: StrictHostKeyChecking;
|
||||||
|
remoteConnectTimeout?: number;
|
||||||
|
remoteProxyJump?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseArgs(argv: string[]): CliArgs {
|
function parseArgs(argv: string[]): CliArgs {
|
||||||
@@ -553,6 +500,7 @@ function parseArgs(argv: string[]): CliArgs {
|
|||||||
theme: "default",
|
theme: "default",
|
||||||
citeStatus: true,
|
citeStatus: true,
|
||||||
dryRun: false,
|
dryRun: false,
|
||||||
|
remote: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let i = 0; i < argv.length; i++) {
|
for (let i = 0; i < argv.length; i++) {
|
||||||
@@ -582,6 +530,47 @@ function parseArgs(argv: string[]): CliArgs {
|
|||||||
args.citeStatus = false;
|
args.citeStatus = false;
|
||||||
} else if (arg === "--dry-run") {
|
} else if (arg === "--dry-run") {
|
||||||
args.dryRun = true;
|
args.dryRun = true;
|
||||||
|
} else if (arg === "--remote") {
|
||||||
|
args.remote = true;
|
||||||
|
} else if (arg === "--remote-host" && argv[i + 1]) {
|
||||||
|
args.remoteHost = argv[++i];
|
||||||
|
args.remote = true;
|
||||||
|
} else if (arg === "--remote-user" && argv[i + 1]) {
|
||||||
|
args.remoteUser = argv[++i];
|
||||||
|
args.remote = true;
|
||||||
|
} else if (arg === "--remote-port" && argv[i + 1]) {
|
||||||
|
const n = Number.parseInt(argv[++i]!, 10);
|
||||||
|
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
||||||
|
console.error(`Error: --remote-port must be 1-65535, got ${argv[i]}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
args.remotePort = n;
|
||||||
|
args.remote = true;
|
||||||
|
} else if (arg === "--remote-identity-file" && argv[i + 1]) {
|
||||||
|
args.remoteIdentityFile = argv[++i];
|
||||||
|
args.remote = true;
|
||||||
|
} else if (arg === "--remote-known-hosts-file" && argv[i + 1]) {
|
||||||
|
args.remoteKnownHostsFile = argv[++i];
|
||||||
|
args.remote = true;
|
||||||
|
} else if (arg === "--remote-strict-host-key-checking" && argv[i + 1]) {
|
||||||
|
const v = argv[++i]!.toLowerCase();
|
||||||
|
if (v !== "yes" && v !== "no" && v !== "accept-new") {
|
||||||
|
console.error(`Error: --remote-strict-host-key-checking must be yes|no|accept-new, got ${argv[i]}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
args.remoteStrictHostKeyChecking = v as StrictHostKeyChecking;
|
||||||
|
args.remote = true;
|
||||||
|
} else if (arg === "--remote-connect-timeout" && argv[i + 1]) {
|
||||||
|
const n = Number.parseInt(argv[++i]!, 10);
|
||||||
|
if (!Number.isInteger(n) || n <= 0) {
|
||||||
|
console.error(`Error: --remote-connect-timeout must be a positive integer, got ${argv[i]}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
args.remoteConnectTimeout = n;
|
||||||
|
args.remote = true;
|
||||||
|
} else if (arg === "--remote-proxy-jump" && argv[i + 1]) {
|
||||||
|
args.remoteProxyJump = argv[++i];
|
||||||
|
args.remote = true;
|
||||||
} else if (arg.startsWith("--") && argv[i + 1] && !argv[i + 1]!.startsWith("-")) {
|
} else if (arg.startsWith("--") && argv[i + 1] && !argv[i + 1]!.startsWith("-")) {
|
||||||
i++;
|
i++;
|
||||||
} else if (!arg.startsWith("-")) {
|
} else if (!arg.startsWith("-")) {
|
||||||
@@ -607,6 +596,27 @@ function extractHtmlTitle(html: string): string {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildRemoteConfig(args: CliArgs, resolved: ResolvedAccount): RemotePublishConfig {
|
||||||
|
const host = args.remoteHost ?? resolved.remote_publish_host;
|
||||||
|
if (!host) {
|
||||||
|
throw new Error(
|
||||||
|
"Remote publishing requires a host. Set --remote-host, EXTEND.md remote_publish_host, " +
|
||||||
|
"or an account-level remote_publish_host.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
user: args.remoteUser ?? resolved.remote_publish_user,
|
||||||
|
port: args.remotePort ?? resolved.remote_publish_port,
|
||||||
|
identityFile: args.remoteIdentityFile ?? resolved.remote_publish_identity_file,
|
||||||
|
knownHostsFile: args.remoteKnownHostsFile ?? resolved.remote_publish_known_hosts_file,
|
||||||
|
strictHostKeyChecking:
|
||||||
|
args.remoteStrictHostKeyChecking ?? resolved.remote_publish_strict_host_key_checking,
|
||||||
|
connectTimeout: args.remoteConnectTimeout ?? resolved.remote_publish_connect_timeout,
|
||||||
|
proxyJump: args.remoteProxyJump ?? resolved.remote_publish_proxy_jump,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const args = parseArgs(process.argv.slice(2));
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
@@ -709,8 +719,6 @@ async function main(): Promise<void> {
|
|||||||
console.error(`[wechat-api] Skipped incomplete credential source: ${skippedSource}`);
|
console.error(`[wechat-api] Skipped incomplete credential source: ${skippedSource}`);
|
||||||
}
|
}
|
||||||
console.error(`[wechat-api] Credentials source: ${creds.source}`);
|
console.error(`[wechat-api] Credentials source: ${creds.source}`);
|
||||||
console.error("[wechat-api] Fetching access token...");
|
|
||||||
const accessToken = await fetchAccessToken(creds.appId, creds.appSecret);
|
|
||||||
|
|
||||||
const rawCoverPath = args.cover ||
|
const rawCoverPath = args.cover ||
|
||||||
frontmatter.coverImage ||
|
frontmatter.coverImage ||
|
||||||
@@ -722,62 +730,80 @@ async function main(): Promise<void> {
|
|||||||
: rawCoverPath;
|
: rawCoverPath;
|
||||||
const needNewsCoverFallback = args.articleType === "news" && !coverPath;
|
const needNewsCoverFallback = args.articleType === "news" && !coverPath;
|
||||||
|
|
||||||
console.error("[wechat-api] Uploading body images...");
|
const useRemote = args.remote || resolved.default_publish_method === "remote-api";
|
||||||
const { html: processedHtml, firstCoverMediaId, imageMediaIds } = await uploadImagesInHtml(
|
const method = useRemote ? "remote-api" : "api";
|
||||||
htmlContent,
|
|
||||||
accessToken,
|
|
||||||
baseDir,
|
|
||||||
contentImages,
|
|
||||||
args.articleType,
|
|
||||||
needNewsCoverFallback,
|
|
||||||
);
|
|
||||||
htmlContent = processedHtml;
|
|
||||||
|
|
||||||
let thumbMediaId = "";
|
const publishWith = async (client: WechatClient): Promise<void> => {
|
||||||
|
console.error("[wechat-api] Fetching access token...");
|
||||||
|
const accessToken = await fetchAccessToken(creds.appId, creds.appSecret, client);
|
||||||
|
|
||||||
if (coverPath) {
|
console.error("[wechat-api] Uploading body images...");
|
||||||
console.error(`[wechat-api] Uploading cover: ${coverPath}`);
|
const { html: processedHtml, firstCoverMediaId, imageMediaIds } = await uploadImagesInHtml(
|
||||||
// 封面图片使用 material/add_material 接口
|
htmlContent,
|
||||||
const coverResp = await uploadImage(coverPath, accessToken, baseDir, "material");
|
accessToken,
|
||||||
thumbMediaId = coverResp.media_id;
|
baseDir,
|
||||||
console.error(`[wechat-api] Cover uploaded successfully, media_id: ${thumbMediaId}`);
|
contentImages,
|
||||||
} else if (firstCoverMediaId && args.articleType === "news") {
|
args.articleType,
|
||||||
// news 类型没有封面时,使用第一张正文图的 media_id 作为封面(兜底逻辑)
|
needNewsCoverFallback,
|
||||||
thumbMediaId = firstCoverMediaId;
|
client,
|
||||||
console.error(`[wechat-api] Using first body image as cover (fallback), media_id: ${thumbMediaId}`);
|
);
|
||||||
|
htmlContent = processedHtml;
|
||||||
|
|
||||||
|
let thumbMediaId = "";
|
||||||
|
|
||||||
|
if (coverPath) {
|
||||||
|
console.error(`[wechat-api] Uploading cover: ${coverPath}`);
|
||||||
|
const coverResp = await uploadImage(coverPath, accessToken, baseDir, "material", client);
|
||||||
|
thumbMediaId = coverResp.media_id;
|
||||||
|
console.error(`[wechat-api] Cover uploaded successfully, media_id: ${thumbMediaId}`);
|
||||||
|
} else if (firstCoverMediaId && args.articleType === "news") {
|
||||||
|
thumbMediaId = firstCoverMediaId;
|
||||||
|
console.error(`[wechat-api] Using first body image as cover (fallback), media_id: ${thumbMediaId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.articleType === "news" && !thumbMediaId) {
|
||||||
|
throw new Error("No cover image. Provide via --cover, frontmatter.coverImage, or include an image in content.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.articleType === "newspic" && imageMediaIds.length === 0) {
|
||||||
|
throw new Error("newspic requires at least one image in content.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("[wechat-api] Publishing to draft...");
|
||||||
|
const result = await publishToDraft({
|
||||||
|
title,
|
||||||
|
author: author || undefined,
|
||||||
|
digest: digest || undefined,
|
||||||
|
content: htmlContent,
|
||||||
|
thumbMediaId,
|
||||||
|
articleType: args.articleType,
|
||||||
|
imageMediaIds: args.articleType === "newspic" ? imageMediaIds : undefined,
|
||||||
|
needOpenComment: resolved.need_open_comment,
|
||||||
|
onlyFansCanComment: resolved.only_fans_can_comment,
|
||||||
|
}, accessToken, client);
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
media_id: result.media_id,
|
||||||
|
title,
|
||||||
|
articleType: args.articleType,
|
||||||
|
method,
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
console.error(`[wechat-api] Published successfully! media_id: ${result.media_id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (useRemote) {
|
||||||
|
const remoteConfig = normalizeRemoteConfig(buildRemoteConfig(args, resolved));
|
||||||
|
console.error(
|
||||||
|
`[wechat-api] Remote publishing via ${remoteConfig.user}@${remoteConfig.host}:${remoteConfig.port}`,
|
||||||
|
);
|
||||||
|
await withSshTunnel(remoteConfig, async (client) => {
|
||||||
|
await publishWith(client);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await publishWith(wechatHttp);
|
||||||
}
|
}
|
||||||
|
|
||||||
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" && imageMediaIds.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,
|
|
||||||
author: author || undefined,
|
|
||||||
digest: digest || undefined,
|
|
||||||
content: htmlContent,
|
|
||||||
thumbMediaId,
|
|
||||||
articleType: args.articleType,
|
|
||||||
imageMediaIds: args.articleType === "newspic" ? imageMediaIds : undefined,
|
|
||||||
needOpenComment: resolved.need_open_comment,
|
|
||||||
onlyFansCanComment: resolved.only_fans_can_comment,
|
|
||||||
}, 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}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await main().catch((err) => {
|
await main().catch((err) => {
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const articleScript = fs.readFileSync(path.join(__dirname, "wechat-article.ts"), "utf8");
|
||||||
|
|
||||||
|
test("browser article paste uses CDP-targeted paste instead of global macOS keystrokes", () => {
|
||||||
|
assert.equal(
|
||||||
|
articleScript.includes('tell application "System Events" to keystroke "v" using command down'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("browser article publishing verifies the title before saving drafts", () => {
|
||||||
|
assert.match(articleScript, /verifyTitleUnchangedBeforeSave/);
|
||||||
|
assert.match(articleScript, /Title was modified during paste/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("browser article body insertion does not paste HTML through the active form field", () => {
|
||||||
|
assert.match(articleScript, /insertHtmlIntoEditorFromFile/);
|
||||||
|
assert.doesNotMatch(articleScript, /await copyHtmlFromBrowser\(cdp, effectiveHtmlFile, contentImages\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("browser article body operations target the body ProseMirror, not the title ProseMirror", () => {
|
||||||
|
assert.match(articleScript, /BODY_EDITOR_SELECTOR = '\.rich_media_content \.ProseMirror'/);
|
||||||
|
assert.doesNotMatch(articleScript, /clickElement\(session, '\\.ProseMirror'\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("browser article reuses the selected account Chrome profile before launching", () => {
|
||||||
|
assert.match(articleScript, /await findExistingChromeDebugPort\(profileDir\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("browser article inserts inline images through WeChat local upload instead of clipboard paste", () => {
|
||||||
|
assert.match(articleScript, /uploadImageThroughFileInput/);
|
||||||
|
assert.match(articleScript, /DOM\.setFileInputFiles/);
|
||||||
|
assert.doesNotMatch(articleScript, /await copyImageToClipboard\(img\.localPath\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("browser article uploads original images before fallback processing", () => {
|
||||||
|
const rawUploadIndex = articleScript.indexOf("await uploadImagePathThroughFileInput(session, absolutePath, beforeCount)");
|
||||||
|
const fallbackIndex = articleScript.indexOf("const fallback = await prepareFallbackWechatBodyImageUpload(absolutePath)");
|
||||||
|
|
||||||
|
assert.notEqual(rawUploadIndex, -1);
|
||||||
|
assert.notEqual(fallbackIndex, -1);
|
||||||
|
assert.ok(rawUploadIndex < fallbackIndex);
|
||||||
|
assert.match(articleScript, /prepareWechatBodyImageUpload/);
|
||||||
|
assert.match(articleScript, /Raw image upload failed, retrying with fallback processing/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("browser article waits for a saved draft appmsgid before reporting success", () => {
|
||||||
|
assert.match(articleScript, /waitForDraftSaved/);
|
||||||
|
assert.match(articleScript, /appmsgid/);
|
||||||
|
assert.doesNotMatch(articleScript, /Waiting for save confirmation/);
|
||||||
|
});
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { launchChrome, tryConnectExisting, findExistingChromeDebugPort, getPageSession, waitForNewTab, clickElement, typeText, evaluate, sleep, getAccountProfileDir, type ChromeSession, type CdpConnection } from './cdp.ts';
|
import { launchChrome, tryConnectExisting, findExistingChromeDebugPort, getPageSession, waitForNewTab, clickElement, typeText, evaluate, sleep, getAccountProfileDir, type ChromeSession, type CdpConnection } from './cdp.ts';
|
||||||
import { loadWechatExtendConfig, resolveAccount } from './wechat-extend-config.ts';
|
import { loadWechatExtendConfig, resolveAccount } from './wechat-extend-config.ts';
|
||||||
|
import { prepareWechatBodyImageUpload } from './wechat-image-processor.ts';
|
||||||
|
|
||||||
const WECHAT_URL = 'https://mp.weixin.qq.com/';
|
const WECHAT_URL = 'https://mp.weixin.qq.com/';
|
||||||
|
const BODY_EDITOR_SELECTOR = '.rich_media_content .ProseMirror';
|
||||||
|
|
||||||
interface ImageInfo {
|
interface ImageInfo {
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
@@ -31,7 +34,98 @@ interface ArticleOptions {
|
|||||||
cdpPort?: number;
|
cdpPort?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendQrToTelegram(session: ChromeSession): Promise<void> {
|
||||||
|
const botToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
const chatId = process.env.TELEGRAM_CHAT_ID;
|
||||||
|
if (!botToken || !chatId) return;
|
||||||
|
|
||||||
|
// Wait for QR to render before extracting
|
||||||
|
await sleep(2000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to extract QR image from DOM first (avoids full-page screenshot noise)
|
||||||
|
const domResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||||
|
expression: `
|
||||||
|
(function() {
|
||||||
|
const selectors = [
|
||||||
|
'.login__type__container__scan img',
|
||||||
|
'.login_img img',
|
||||||
|
'#login_container img',
|
||||||
|
'.qrcode img',
|
||||||
|
'img[src*="qrcode"]',
|
||||||
|
'img[src*="login"]',
|
||||||
|
];
|
||||||
|
for (const sel of selectors) {
|
||||||
|
const el = document.querySelector(sel);
|
||||||
|
if (el?.src && !el.src.startsWith('data:,')) return el.src.startsWith('data:') ? el.src : 'url:' + el.src;
|
||||||
|
}
|
||||||
|
const canvas = document.querySelector('canvas');
|
||||||
|
if (canvas) try { return canvas.toDataURL('image/png'); } catch {}
|
||||||
|
return '';
|
||||||
|
})()
|
||||||
|
`,
|
||||||
|
returnByValue: true,
|
||||||
|
}, { sessionId: session.sessionId });
|
||||||
|
|
||||||
|
const raw = (domResult.result.value as string) ?? '';
|
||||||
|
let imgBuffer: Buffer;
|
||||||
|
|
||||||
|
if (raw.startsWith('data:image')) {
|
||||||
|
imgBuffer = Buffer.from(raw.split(',')[1] ?? '', 'base64');
|
||||||
|
} else if (raw.startsWith('url:')) {
|
||||||
|
// Fetch inside Chrome to carry WeChat session cookies
|
||||||
|
const imgUrl = raw.slice(4);
|
||||||
|
const inBrowserFetch = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||||
|
expression: `
|
||||||
|
(async () => {
|
||||||
|
const resp = await fetch(${JSON.stringify(imgUrl)}, { credentials: 'include' });
|
||||||
|
const buf = await resp.arrayBuffer();
|
||||||
|
const bytes = new Uint8Array(buf);
|
||||||
|
let b = '';
|
||||||
|
for (let i = 0; i < bytes.length; i++) b += String.fromCharCode(bytes[i]);
|
||||||
|
return btoa(b);
|
||||||
|
})()
|
||||||
|
`,
|
||||||
|
returnByValue: true,
|
||||||
|
awaitPromise: true,
|
||||||
|
}, { sessionId: session.sessionId });
|
||||||
|
imgBuffer = Buffer.from((inBrowserFetch.result.value as string) ?? '', 'base64');
|
||||||
|
} else {
|
||||||
|
// Fallback: viewport screenshot (smaller than full-page; QR is usually in viewport)
|
||||||
|
const screenshotResp = await session.cdp.send<{ data: string }>(
|
||||||
|
'Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }, { sessionId: session.sessionId }
|
||||||
|
);
|
||||||
|
imgBuffer = Buffer.from(screenshotResp.data ?? '', 'base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundary = `tgboundary${Date.now()}`;
|
||||||
|
const parts: Buffer[] = [
|
||||||
|
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="chat_id"\r\n\r\n${chatId}\r\n`),
|
||||||
|
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="caption"\r\n\r\nWeChat QR code — please scan to log in\r\n`),
|
||||||
|
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="photo"; filename="qr.png"\r\nContent-Type: image/png\r\n\r\n`),
|
||||||
|
imgBuffer,
|
||||||
|
Buffer.from(`\r\n--${boundary}--\r\n`),
|
||||||
|
];
|
||||||
|
const tgResp = await fetch(`https://api.telegram.org/bot${botToken}/sendPhoto`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` },
|
||||||
|
body: Buffer.concat(parts),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
const tgJson = await tgResp.json() as { ok: boolean; description?: string };
|
||||||
|
if (tgJson.ok) {
|
||||||
|
console.log('[wechat] QR code sent to Telegram.');
|
||||||
|
} else {
|
||||||
|
console.error('[wechat] Telegram send failed:', tgJson.description);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[wechat] Failed to send QR to Telegram:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForLogin(session: ChromeSession, timeoutMs = 120_000): Promise<boolean> {
|
async function waitForLogin(session: ChromeSession, timeoutMs = 120_000): Promise<boolean> {
|
||||||
|
// Notify via Telegram if configured (no-op when env vars absent)
|
||||||
|
await sendQrToTelegram(session);
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
while (Date.now() - start < timeoutMs) {
|
while (Date.now() - start < timeoutMs) {
|
||||||
const url = await evaluate<string>(session, 'window.location.href');
|
const url = await evaluate<string>(session, 'window.location.href');
|
||||||
@@ -105,27 +199,27 @@ async function pasteInEditor(session: ChromeSession): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sendCopy(cdp?: CdpConnection, sessionId?: string): Promise<void> {
|
async function sendCopy(cdp?: CdpConnection, sessionId?: string): Promise<void> {
|
||||||
if (process.platform === 'darwin') {
|
if (cdp && sessionId) {
|
||||||
|
const modifiers = process.platform === 'darwin' ? 4 : 2;
|
||||||
|
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'c', code: 'KeyC', modifiers, windowsVirtualKeyCode: 67 }, { sessionId });
|
||||||
|
await sleep(50);
|
||||||
|
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'c', code: 'KeyC', modifiers, windowsVirtualKeyCode: 67 }, { sessionId });
|
||||||
|
} else if (process.platform === 'darwin') {
|
||||||
spawnSync('osascript', ['-e', 'tell application "System Events" to keystroke "c" using command down']);
|
spawnSync('osascript', ['-e', 'tell application "System Events" to keystroke "c" using command down']);
|
||||||
} else if (process.platform === 'linux') {
|
} else if (process.platform === 'linux') {
|
||||||
spawnSync('xdotool', ['key', 'ctrl+c']);
|
spawnSync('xdotool', ['key', 'ctrl+c']);
|
||||||
} else if (cdp && sessionId) {
|
|
||||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'c', code: 'KeyC', modifiers: 2, windowsVirtualKeyCode: 67 }, { sessionId });
|
|
||||||
await sleep(50);
|
|
||||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'c', code: 'KeyC', modifiers: 2, windowsVirtualKeyCode: 67 }, { sessionId });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendPaste(cdp?: CdpConnection, sessionId?: string): Promise<void> {
|
async function sendPaste(cdp?: CdpConnection, sessionId?: string): Promise<void> {
|
||||||
if (process.platform === 'darwin') {
|
if (!cdp || !sessionId) {
|
||||||
spawnSync('osascript', ['-e', 'tell application "System Events" to keystroke "v" using command down']);
|
throw new Error('Targeted paste requires a Chrome DevTools session');
|
||||||
} else if (process.platform === 'linux') {
|
|
||||||
spawnSync('xdotool', ['key', 'ctrl+v']);
|
|
||||||
} else if (cdp && sessionId) {
|
|
||||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'v', code: 'KeyV', modifiers: 2, windowsVirtualKeyCode: 86 }, { sessionId });
|
|
||||||
await sleep(50);
|
|
||||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'v', code: 'KeyV', modifiers: 2, windowsVirtualKeyCode: 86 }, { sessionId });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modifiers = process.platform === 'darwin' ? 4 : 2;
|
||||||
|
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId });
|
||||||
|
await sleep(50);
|
||||||
|
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyHtmlFromBrowser(cdp: CdpConnection, htmlFilePath: string, contentImages: ImageInfo[] = []): Promise<void> {
|
async function copyHtmlFromBrowser(cdp: CdpConnection, htmlFilePath: string, contentImages: ImageInfo[] = []): Promise<void> {
|
||||||
@@ -203,6 +297,82 @@ async function pasteFromClipboardInEditor(session: ChromeSession): Promise<void>
|
|||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function insertHtmlIntoEditorFromFile(
|
||||||
|
session: ChromeSession,
|
||||||
|
htmlFilePath: string,
|
||||||
|
contentImages: ImageInfo[] = [],
|
||||||
|
): Promise<void> {
|
||||||
|
const absolutePath = path.isAbsolute(htmlFilePath) ? htmlFilePath : path.resolve(process.cwd(), htmlFilePath);
|
||||||
|
const html = fs.readFileSync(absolutePath, 'utf8');
|
||||||
|
const replacements = contentImages.map(img => ({ placeholder: img.placeholder, localPath: img.localPath }));
|
||||||
|
|
||||||
|
const result = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||||
|
expression: `
|
||||||
|
(function() {
|
||||||
|
const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
|
||||||
|
if (!editor) return JSON.stringify({ ok: false, reason: 'editor-missing' });
|
||||||
|
|
||||||
|
const template = document.createElement('template');
|
||||||
|
template.innerHTML = ${JSON.stringify(html)};
|
||||||
|
const replacements = ${JSON.stringify(replacements)};
|
||||||
|
|
||||||
|
for (const img of Array.from(template.content.querySelectorAll('img'))) {
|
||||||
|
const src = img.getAttribute('src') || '';
|
||||||
|
const localPath = img.getAttribute('data-local-path') || '';
|
||||||
|
const replacement = replacements.find((item) => item.placeholder === src || item.localPath === localPath);
|
||||||
|
if (replacement) {
|
||||||
|
img.replaceWith(document.createTextNode(replacement.placeholder));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = template.content.querySelector('#output');
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
if (output) {
|
||||||
|
wrapper.innerHTML = output.innerHTML;
|
||||||
|
} else {
|
||||||
|
wrapper.appendChild(template.content.cloneNode(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.focus();
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(editor);
|
||||||
|
range.deleteContents();
|
||||||
|
range.collapse(true);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
|
||||||
|
const inserted = document.execCommand('insertHTML', false, wrapper.innerHTML);
|
||||||
|
editor.dispatchEvent(new InputEvent('input', {
|
||||||
|
bubbles: true,
|
||||||
|
inputType: 'insertHTML',
|
||||||
|
data: wrapper.innerText || ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
ok: inserted || (editor.innerText || '').trim().length > 0,
|
||||||
|
textLength: (editor.innerText || '').trim().length
|
||||||
|
});
|
||||||
|
})()
|
||||||
|
`,
|
||||||
|
returnByValue: true,
|
||||||
|
}, { sessionId: session.sessionId });
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result.result.value || '{}') as { ok?: boolean; reason?: string; textLength?: number };
|
||||||
|
if (!parsed.ok) {
|
||||||
|
throw new Error(`Failed to insert HTML into body editor${parsed.reason ? `: ${parsed.reason}` : ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyTitleUnchangedBeforeSave(session: ChromeSession, expectedTitle: string): Promise<void> {
|
||||||
|
if (!expectedTitle) return;
|
||||||
|
|
||||||
|
const actualTitle = await evaluate<string>(session, `document.querySelector('#title')?.value || ''`);
|
||||||
|
if (actualTitle !== expectedTitle) {
|
||||||
|
throw new Error(`Title was modified during paste. Expected: "${expectedTitle}", got: "${actualTitle}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function prepareEditorPasteTarget(
|
async function prepareEditorPasteTarget(
|
||||||
session: ChromeSession,
|
session: ChromeSession,
|
||||||
context: string,
|
context: string,
|
||||||
@@ -212,19 +382,24 @@ async function prepareEditorPasteTarget(
|
|||||||
await sleep(100);
|
await sleep(100);
|
||||||
|
|
||||||
if (options.clickEditor) {
|
if (options.clickEditor) {
|
||||||
await clickElement(session, '.ProseMirror');
|
await clickElement(session, BODY_EDITOR_SELECTOR);
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ready = await evaluate<boolean>(session, `
|
const ready = await evaluate<boolean>(session, `
|
||||||
(function() {
|
(function() {
|
||||||
const editor = document.querySelector('.ProseMirror');
|
const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
|
||||||
if (!editor) return false;
|
if (!editor) return false;
|
||||||
|
|
||||||
const active = document.activeElement;
|
const active = document.activeElement;
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
const selectionInEditor = !!selection && selection.rangeCount > 0 && !!selection.anchorNode && editor.contains(selection.anchorNode);
|
const selectionInEditor = !!selection && selection.rangeCount > 0 && !!selection.anchorNode && editor.contains(selection.anchorNode);
|
||||||
const focusInEditor = !!active && (active === editor || editor.contains(active));
|
const focusInEditor = !!active && (active === editor || editor.contains(active));
|
||||||
|
const activeIsUnsafeInput = !!active && (
|
||||||
|
active.matches?.('#title, #author, #js_description') ||
|
||||||
|
((active.tagName === 'INPUT' || active.tagName === 'TEXTAREA') && !editor.contains(active))
|
||||||
|
);
|
||||||
|
if (activeIsUnsafeInput) return false;
|
||||||
if (selectionInEditor || focusInEditor) return true;
|
if (selectionInEditor || focusInEditor) return true;
|
||||||
|
|
||||||
if (${JSON.stringify(Boolean(options.clickEditor))}) {
|
if (${JSON.stringify(Boolean(options.clickEditor))}) {
|
||||||
@@ -347,7 +522,7 @@ async function selectAndReplacePlaceholder(session: ChromeSession, placeholder:
|
|||||||
const result = await session.cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
|
const result = await session.cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
|
||||||
expression: `
|
expression: `
|
||||||
(function() {
|
(function() {
|
||||||
const editor = document.querySelector('.ProseMirror');
|
const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
|
||||||
if (!editor) return false;
|
if (!editor) return false;
|
||||||
|
|
||||||
const placeholder = ${JSON.stringify(placeholder)};
|
const placeholder = ${JSON.stringify(placeholder)};
|
||||||
@@ -365,6 +540,7 @@ async function selectAndReplacePlaceholder(session: ChromeSession, placeholder:
|
|||||||
// Exact match if next char is not a digit
|
// Exact match if next char is not a digit
|
||||||
if (charAfter === undefined || !/\\d/.test(charAfter)) {
|
if (charAfter === undefined || !/\\d/.test(charAfter)) {
|
||||||
node.parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
node.parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
editor.focus();
|
||||||
|
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
range.setStart(node, idx);
|
range.setStart(node, idx);
|
||||||
@@ -395,7 +571,7 @@ async function pressDeleteKey(session: ChromeSession): Promise<void> {
|
|||||||
async function removeExtraEmptyLineAfterImage(session: ChromeSession): Promise<boolean> {
|
async function removeExtraEmptyLineAfterImage(session: ChromeSession): Promise<boolean> {
|
||||||
const removed = await evaluate<boolean>(session, `
|
const removed = await evaluate<boolean>(session, `
|
||||||
(function() {
|
(function() {
|
||||||
const editor = document.querySelector('.ProseMirror');
|
const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
|
||||||
if (!editor) return false;
|
if (!editor) return false;
|
||||||
|
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
@@ -457,6 +633,188 @@ async function removeExtraEmptyLineAfterImage(session: ChromeSession): Promise<b
|
|||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getBodyImageCount(session: ChromeSession): Promise<number> {
|
||||||
|
return await evaluate<number>(session, `
|
||||||
|
(function() {
|
||||||
|
const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
|
||||||
|
if (!editor) return 0;
|
||||||
|
return Array.from(editor.querySelectorAll('img')).filter((img) => !img.classList.contains('ProseMirror-separator')).length;
|
||||||
|
})()
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForBodyImageCount(session: ChromeSession, minimumCount: number, timeoutMs = 45_000): Promise<boolean> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
const count = await getBodyImageCount(session);
|
||||||
|
if (count >= minimumCount) return true;
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferImageContentType(imagePath: string): string {
|
||||||
|
const ext = path.extname(imagePath).toLowerCase();
|
||||||
|
switch (ext) {
|
||||||
|
case '.jpg':
|
||||||
|
case '.jpeg':
|
||||||
|
return 'image/jpeg';
|
||||||
|
case '.png':
|
||||||
|
return 'image/png';
|
||||||
|
case '.gif':
|
||||||
|
return 'image/gif';
|
||||||
|
case '.webp':
|
||||||
|
return 'image/webp';
|
||||||
|
case '.bmp':
|
||||||
|
return 'image/bmp';
|
||||||
|
case '.svg':
|
||||||
|
return 'image/svg+xml';
|
||||||
|
default:
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadImagePathThroughFileInput(
|
||||||
|
session: ChromeSession,
|
||||||
|
absolutePath: string,
|
||||||
|
beforeCount: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const documentNode = await session.cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {
|
||||||
|
depth: -1,
|
||||||
|
pierce: true,
|
||||||
|
}, { sessionId: session.sessionId });
|
||||||
|
const inputNode = await session.cdp.send<{ nodeId: number }>('DOM.querySelector', {
|
||||||
|
nodeId: documentNode.root.nodeId,
|
||||||
|
selector: 'input[type="file"][accept*="image"]',
|
||||||
|
}, { sessionId: session.sessionId });
|
||||||
|
|
||||||
|
if (!inputNode.nodeId) throw new Error('WeChat local image upload input not found');
|
||||||
|
|
||||||
|
await session.cdp.send('DOM.setFileInputFiles', {
|
||||||
|
nodeId: inputNode.nodeId,
|
||||||
|
files: [absolutePath],
|
||||||
|
}, { sessionId: session.sessionId });
|
||||||
|
|
||||||
|
const inserted = await waitForBodyImageCount(session, beforeCount + 1);
|
||||||
|
if (!inserted) {
|
||||||
|
const afterCount = await getBodyImageCount(session);
|
||||||
|
throw new Error(`Image upload did not insert into editor: ${path.basename(absolutePath)} (${beforeCount} -> ${afterCount})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FallbackUploadImage {
|
||||||
|
uploadPath: string;
|
||||||
|
wasProcessed: boolean;
|
||||||
|
processingNotes: string[];
|
||||||
|
cleanup: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareFallbackWechatBodyImageUpload(absolutePath: string): Promise<FallbackUploadImage> {
|
||||||
|
const buffer = fs.readFileSync(absolutePath);
|
||||||
|
const prepared = await prepareWechatBodyImageUpload({
|
||||||
|
buffer,
|
||||||
|
filename: path.basename(absolutePath),
|
||||||
|
contentType: inferImageContentType(absolutePath),
|
||||||
|
fileExt: path.extname(absolutePath).toLowerCase(),
|
||||||
|
fileSize: buffer.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!prepared.wasProcessed) {
|
||||||
|
return {
|
||||||
|
uploadPath: absolutePath,
|
||||||
|
wasProcessed: false,
|
||||||
|
processingNotes: [],
|
||||||
|
cleanup: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-body-image-'));
|
||||||
|
const uploadPath = path.join(tempDir, prepared.filename);
|
||||||
|
fs.writeFileSync(uploadPath, prepared.buffer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadPath,
|
||||||
|
wasProcessed: true,
|
||||||
|
processingNotes: prepared.processingNotes,
|
||||||
|
cleanup: () => fs.rmSync(tempDir, { recursive: true, force: true }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadImageThroughFileInput(session: ChromeSession, imagePath: string): Promise<void> {
|
||||||
|
const absolutePath = path.isAbsolute(imagePath) ? imagePath : path.resolve(process.cwd(), imagePath);
|
||||||
|
if (!fs.existsSync(absolutePath)) throw new Error(`Image file not found: ${absolutePath}`);
|
||||||
|
|
||||||
|
const beforeCount = await getBodyImageCount(session);
|
||||||
|
try {
|
||||||
|
await uploadImagePathThroughFileInput(session, absolutePath, beforeCount);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (message.includes('local image upload input not found')) throw err;
|
||||||
|
|
||||||
|
const currentCount = await getBodyImageCount(session);
|
||||||
|
if (currentCount > beforeCount) return;
|
||||||
|
|
||||||
|
console.warn(`[wechat] Raw image upload failed, retrying with fallback processing: ${message}`);
|
||||||
|
const fallback = await prepareFallbackWechatBodyImageUpload(absolutePath);
|
||||||
|
const notes = fallback.processingNotes.length > 0 ? ` (${fallback.processingNotes.join('; ')})` : '';
|
||||||
|
console.log(`[wechat] Retrying image upload with ${fallback.wasProcessed ? 'processed' : 'original'} file: ${path.basename(fallback.uploadPath)}${notes}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadImagePathThroughFileInput(session, fallback.uploadPath, currentCount);
|
||||||
|
} finally {
|
||||||
|
fallback.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DraftSaveStatus {
|
||||||
|
appmsgid: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
submitText: string;
|
||||||
|
url: string;
|
||||||
|
messages: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDraftSaveStatus(session: ChromeSession): Promise<DraftSaveStatus> {
|
||||||
|
const raw = await evaluate<string>(session, `
|
||||||
|
(function() {
|
||||||
|
const submit = document.querySelector('#js_submit');
|
||||||
|
const button = submit?.querySelector('button');
|
||||||
|
const url = location.href;
|
||||||
|
const appmsgid = new URL(url).searchParams.get('appmsgid') || '';
|
||||||
|
const messages = Array.from(document.querySelectorAll('.weui-desktop-toast, .weui-desktop-toptips, .js_tips'))
|
||||||
|
.map((el) => (el.innerText || el.textContent || '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return JSON.stringify({
|
||||||
|
appmsgid,
|
||||||
|
isLoading: !!submit?.classList.contains('btn_loading') || !!button?.disabled,
|
||||||
|
submitText: (submit?.innerText || '').trim(),
|
||||||
|
url,
|
||||||
|
messages
|
||||||
|
});
|
||||||
|
})()
|
||||||
|
`);
|
||||||
|
return JSON.parse(raw || '{}') as DraftSaveStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForDraftSaved(session: ChromeSession, timeoutMs = 60_000): Promise<string> {
|
||||||
|
const start = Date.now();
|
||||||
|
let lastStatus: DraftSaveStatus | null = null;
|
||||||
|
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
lastStatus = await getDraftSaveStatus(session);
|
||||||
|
if (lastStatus.appmsgid && !lastStatus.isLoading) return lastStatus.appmsgid;
|
||||||
|
|
||||||
|
const relevantFailure = lastStatus.messages.find((message) => /保存.*失败|草稿.*失败|save.*fail/i.test(message));
|
||||||
|
if (relevantFailure) throw new Error(`Draft save failed: ${relevantFailure}`);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Draft save did not complete${lastStatus ? `: ${JSON.stringify(lastStatus)}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function postArticle(options: ArticleOptions): Promise<void> {
|
export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||||
const { title, content, htmlFile, markdownFile, theme, color, citeStatus = true, author, summary, images = [], submit = false, profileDir, cdpPort } = options;
|
const { title, content, htmlFile, markdownFile, theme, color, citeStatus = true, author, summary, images = [], submit = false, profileDir, cdpPort } = options;
|
||||||
let { contentImages = [] } = options;
|
let { contentImages = [] } = options;
|
||||||
@@ -500,7 +858,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
|||||||
let chrome: ReturnType<typeof import('node:child_process').spawn> | null = null;
|
let chrome: ReturnType<typeof import('node:child_process').spawn> | null = null;
|
||||||
|
|
||||||
// Try connecting to existing Chrome: explicit port > auto-detect > launch new
|
// Try connecting to existing Chrome: explicit port > auto-detect > launch new
|
||||||
const portToTry = cdpPort ?? await findExistingChromeDebugPort();
|
const portToTry = cdpPort ?? await findExistingChromeDebugPort(profileDir);
|
||||||
if (portToTry) {
|
if (portToTry) {
|
||||||
const existing = await tryConnectExisting(portToTry);
|
const existing = await tryConnectExisting(portToTry);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -557,7 +915,8 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
|||||||
|
|
||||||
const url = await evaluate<string>(session, 'window.location.href');
|
const url = await evaluate<string>(session, 'window.location.href');
|
||||||
if (!url.includes('/cgi-bin/')) {
|
if (!url.includes('/cgi-bin/')) {
|
||||||
console.log('[wechat] Not logged in. Please scan QR code...');
|
const hasTelegram = !!(process.env.TELEGRAM_BOT_TOKEN && process.env.TELEGRAM_CHAT_ID);
|
||||||
|
console.log(`[wechat] Not logged in. Please scan QR code...${hasTelegram ? ' (sending to Telegram)' : ''}`);
|
||||||
const loggedIn = await waitForLogin(session);
|
const loggedIn = await waitForLogin(session);
|
||||||
if (!loggedIn) throw new Error('Login timeout');
|
if (!loggedIn) throw new Error('Login timeout');
|
||||||
}
|
}
|
||||||
@@ -588,7 +947,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
|||||||
console.log('[wechat] Waiting for editor to load...');
|
console.log('[wechat] Waiting for editor to load...');
|
||||||
const editorLoaded = await waitForElement(session, '#title', 30_000);
|
const editorLoaded = await waitForElement(session, '#title', 30_000);
|
||||||
if (!editorLoaded) throw new Error('Editor did not load (#title not found)');
|
if (!editorLoaded) throw new Error('Editor did not load (#title not found)');
|
||||||
await waitForElement(session, '.ProseMirror', 15_000);
|
await waitForElement(session, BODY_EDITOR_SELECTOR, 15_000);
|
||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
|
|
||||||
if (effectiveTitle) {
|
if (effectiveTitle) {
|
||||||
@@ -613,25 +972,23 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('[wechat] Clicking on editor...');
|
console.log('[wechat] Clicking on editor...');
|
||||||
await clickElement(session, '.ProseMirror');
|
await clickElement(session, BODY_EDITOR_SELECTOR);
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
|
|
||||||
console.log('[wechat] Ensuring editor focus...');
|
console.log('[wechat] Ensuring editor focus...');
|
||||||
await clickElement(session, '.ProseMirror');
|
await clickElement(session, BODY_EDITOR_SELECTOR);
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
if (effectiveHtmlFile && fs.existsSync(effectiveHtmlFile)) {
|
if (effectiveHtmlFile && fs.existsSync(effectiveHtmlFile)) {
|
||||||
console.log(`[wechat] Copying HTML content from: ${effectiveHtmlFile}`);
|
console.log(`[wechat] Inserting HTML content from: ${effectiveHtmlFile}`);
|
||||||
await copyHtmlFromBrowser(cdp, effectiveHtmlFile, contentImages);
|
|
||||||
await sleep(500);
|
|
||||||
await prepareEditorPasteTarget(session, 'body content paste', { clickEditor: true });
|
await prepareEditorPasteTarget(session, 'body content paste', { clickEditor: true });
|
||||||
console.log('[wechat] Pasting into editor...');
|
await insertHtmlIntoEditorFromFile(session, effectiveHtmlFile, contentImages);
|
||||||
await pasteFromClipboardInEditor(session);
|
|
||||||
await sleep(3000);
|
await sleep(3000);
|
||||||
|
await verifyTitleUnchangedBeforeSave(session, effectiveTitle);
|
||||||
|
|
||||||
const editorHasContent = await evaluate<boolean>(session, `
|
const editorHasContent = await evaluate<boolean>(session, `
|
||||||
(function() {
|
(function() {
|
||||||
const editor = document.querySelector('.ProseMirror');
|
const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
|
||||||
if (!editor) return false;
|
if (!editor) return false;
|
||||||
const text = editor.innerText?.trim() || '';
|
const text = editor.innerText?.trim() || '';
|
||||||
return text.length > 0;
|
return text.length > 0;
|
||||||
@@ -657,18 +1014,15 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
|||||||
|
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
console.log(`[wechat] Copying image: ${path.basename(img.localPath)}`);
|
|
||||||
await copyImageToClipboard(img.localPath);
|
|
||||||
await sleep(300);
|
|
||||||
|
|
||||||
console.log('[wechat] Deleting placeholder with Backspace...');
|
console.log('[wechat] Deleting placeholder with Backspace...');
|
||||||
await pressDeleteKey(session);
|
await pressDeleteKey(session);
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
|
|
||||||
console.log('[wechat] Pasting image...');
|
console.log(`[wechat] Uploading image: ${path.basename(img.localPath)}`);
|
||||||
await prepareEditorPasteTarget(session, 'inline image paste');
|
await prepareEditorPasteTarget(session, 'inline image upload');
|
||||||
await pasteFromClipboardInEditor(session);
|
await uploadImageThroughFileInput(session, img.localPath);
|
||||||
await sleep(3000);
|
await sleep(1000);
|
||||||
|
await verifyTitleUnchangedBeforeSave(session, effectiveTitle);
|
||||||
await removeExtraEmptyLineAfterImage(session);
|
await removeExtraEmptyLineAfterImage(session);
|
||||||
}
|
}
|
||||||
console.log('[wechat] All images inserted.');
|
console.log('[wechat] All images inserted.');
|
||||||
@@ -676,12 +1030,10 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
|||||||
} else if (content) {
|
} else if (content) {
|
||||||
for (const img of images) {
|
for (const img of images) {
|
||||||
if (fs.existsSync(img)) {
|
if (fs.existsSync(img)) {
|
||||||
console.log(`[wechat] Pasting image: ${img}`);
|
console.log(`[wechat] Uploading image: ${img}`);
|
||||||
await copyImageToClipboard(img);
|
await prepareEditorPasteTarget(session, 'leading image upload');
|
||||||
await sleep(500);
|
await uploadImageThroughFileInput(session, img);
|
||||||
await prepareEditorPasteTarget(session, 'leading image paste');
|
await sleep(1000);
|
||||||
await pasteInEditor(session);
|
|
||||||
await sleep(2000);
|
|
||||||
await removeExtraEmptyLineAfterImage(session);
|
await removeExtraEmptyLineAfterImage(session);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -693,7 +1045,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
|||||||
|
|
||||||
const editorHasContent = await evaluate<boolean>(session, `
|
const editorHasContent = await evaluate<boolean>(session, `
|
||||||
(function() {
|
(function() {
|
||||||
const editor = document.querySelector('.ProseMirror');
|
const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
|
||||||
if (!editor) return false;
|
if (!editor) return false;
|
||||||
const text = editor.innerText?.trim() || '';
|
const text = editor.innerText?.trim() || '';
|
||||||
return text.length > 0;
|
return text.length > 0;
|
||||||
@@ -730,17 +1082,12 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await verifyTitleUnchangedBeforeSave(session, effectiveTitle);
|
||||||
|
|
||||||
console.log('[wechat] Saving as draft...');
|
console.log('[wechat] Saving as draft...');
|
||||||
await evaluate(session, `document.querySelector('#js_submit button').click()`);
|
await evaluate(session, `document.querySelector('#js_submit button').click()`);
|
||||||
await sleep(3000);
|
const appmsgid = await waitForDraftSaved(session);
|
||||||
|
console.log(`[wechat] Draft saved successfully! appmsgid: ${appmsgid}`);
|
||||||
const saved = await evaluate<boolean>(session, `!!document.querySelector('.weui-desktop-toast')`);
|
|
||||||
if (saved) {
|
|
||||||
console.log('[wechat] Draft saved successfully!');
|
|
||||||
} else {
|
|
||||||
console.log('[wechat] Waiting for save confirmation...');
|
|
||||||
await sleep(5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[wechat] Done. Browser window left open.');
|
console.log('[wechat] Done. Browser window left open.');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import path from "node:path";
|
|||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import test, { type TestContext } from "node:test";
|
import test, { type TestContext } from "node:test";
|
||||||
|
|
||||||
import { loadCredentials } from "./wechat-extend-config.ts";
|
import {
|
||||||
|
loadCredentials,
|
||||||
|
loadWechatExtendConfig,
|
||||||
|
resolveAccount,
|
||||||
|
} from "./wechat-extend-config.ts";
|
||||||
|
|
||||||
function useCwd(t: TestContext, cwd: string): void {
|
function useCwd(t: TestContext, cwd: string): void {
|
||||||
const previous = process.cwd();
|
const previous = process.cwd();
|
||||||
@@ -73,6 +77,28 @@ async function writeEnvFile(root: string, content: string): Promise<void> {
|
|||||||
await fs.writeFile(envPath, content);
|
await fs.writeFile(envPath, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function writeExtendFile(root: string, content: string): Promise<void> {
|
||||||
|
const extendPath = path.join(root, ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md");
|
||||||
|
await fs.mkdir(path.dirname(extendPath), { recursive: true });
|
||||||
|
await fs.writeFile(extendPath, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useXdgConfigHome(t: TestContext, value: string | undefined): void {
|
||||||
|
const previous = process.env.XDG_CONFIG_HOME;
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env.XDG_CONFIG_HOME;
|
||||||
|
} else {
|
||||||
|
process.env.XDG_CONFIG_HOME = value;
|
||||||
|
}
|
||||||
|
t.after(() => {
|
||||||
|
if (previous === undefined) {
|
||||||
|
delete process.env.XDG_CONFIG_HOME;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
process.env.XDG_CONFIG_HOME = previous;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test("loadCredentials selects the first complete source without mixing values across sources", async (t) => {
|
test("loadCredentials selects the first complete source without mixing values across sources", async (t) => {
|
||||||
const cwdRoot = await makeTempDir("wechat-creds-cwd-");
|
const cwdRoot = await makeTempDir("wechat-creds-cwd-");
|
||||||
const homeRoot = await makeTempDir("wechat-creds-home-");
|
const homeRoot = await makeTempDir("wechat-creds-home-");
|
||||||
@@ -119,6 +145,149 @@ test("loadCredentials prefers a complete process.env pair over lower-priority fi
|
|||||||
assert.deepEqual(credentials.skippedSources, []);
|
assert.deepEqual(credentials.skippedSources, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("resolveAccount returns global remote_publish_* values when no account is configured", async (t) => {
|
||||||
|
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
|
||||||
|
const homeRoot = await makeTempDir("wechat-extend-home-");
|
||||||
|
|
||||||
|
useCwd(t, cwdRoot);
|
||||||
|
useHome(t, homeRoot);
|
||||||
|
useXdgConfigHome(t, undefined);
|
||||||
|
|
||||||
|
await writeExtendFile(
|
||||||
|
cwdRoot,
|
||||||
|
[
|
||||||
|
"default_publish_method: remote-api",
|
||||||
|
"remote_publish_host: bastion.example.com",
|
||||||
|
"remote_publish_user: deploy",
|
||||||
|
"remote_publish_port: 2222",
|
||||||
|
"remote_publish_identity_file: /home/me/.ssh/id_ed25519",
|
||||||
|
"remote_publish_known_hosts_file: /home/me/.ssh/known_hosts",
|
||||||
|
"remote_publish_strict_host_key_checking: accept-new",
|
||||||
|
"remote_publish_connect_timeout: 12",
|
||||||
|
"remote_publish_proxy_jump: jump.example.com",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = loadWechatExtendConfig();
|
||||||
|
const resolved = resolveAccount(config);
|
||||||
|
|
||||||
|
assert.equal(resolved.default_publish_method, "remote-api");
|
||||||
|
assert.equal(resolved.remote_publish_host, "bastion.example.com");
|
||||||
|
assert.equal(resolved.remote_publish_user, "deploy");
|
||||||
|
assert.equal(resolved.remote_publish_port, 2222);
|
||||||
|
assert.equal(resolved.remote_publish_identity_file, "/home/me/.ssh/id_ed25519");
|
||||||
|
assert.equal(resolved.remote_publish_known_hosts_file, "/home/me/.ssh/known_hosts");
|
||||||
|
assert.equal(resolved.remote_publish_strict_host_key_checking, "accept-new");
|
||||||
|
assert.equal(resolved.remote_publish_connect_timeout, 12);
|
||||||
|
assert.equal(resolved.remote_publish_proxy_jump, "jump.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveAccount lets account-level remote_publish_* override globals", async (t) => {
|
||||||
|
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
|
||||||
|
const homeRoot = await makeTempDir("wechat-extend-home-");
|
||||||
|
|
||||||
|
useCwd(t, cwdRoot);
|
||||||
|
useHome(t, homeRoot);
|
||||||
|
useXdgConfigHome(t, undefined);
|
||||||
|
|
||||||
|
await writeExtendFile(
|
||||||
|
cwdRoot,
|
||||||
|
[
|
||||||
|
"default_publish_method: browser",
|
||||||
|
"remote_publish_host: global.example.com",
|
||||||
|
"remote_publish_user: deploy",
|
||||||
|
"remote_publish_port: 22",
|
||||||
|
"accounts:",
|
||||||
|
" - name: Primary",
|
||||||
|
" alias: primary",
|
||||||
|
" default: true",
|
||||||
|
" remote_publish_host: primary.example.com",
|
||||||
|
" remote_publish_user: primary-user",
|
||||||
|
" remote_publish_port: 2200",
|
||||||
|
" remote_publish_identity_file: /p/id_primary",
|
||||||
|
" - name: Secondary",
|
||||||
|
" alias: secondary",
|
||||||
|
" remote_publish_proxy_jump: jump.example.com",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = loadWechatExtendConfig();
|
||||||
|
const primary = resolveAccount(config, "primary");
|
||||||
|
assert.equal(primary.alias, "primary");
|
||||||
|
assert.equal(primary.remote_publish_host, "primary.example.com");
|
||||||
|
assert.equal(primary.remote_publish_user, "primary-user");
|
||||||
|
assert.equal(primary.remote_publish_port, 2200);
|
||||||
|
assert.equal(primary.remote_publish_identity_file, "/p/id_primary");
|
||||||
|
assert.equal(primary.remote_publish_proxy_jump, undefined);
|
||||||
|
|
||||||
|
const secondary = resolveAccount(config, "secondary");
|
||||||
|
assert.equal(secondary.alias, "secondary");
|
||||||
|
assert.equal(secondary.remote_publish_host, "global.example.com");
|
||||||
|
assert.equal(secondary.remote_publish_user, "deploy");
|
||||||
|
assert.equal(secondary.remote_publish_port, 22);
|
||||||
|
assert.equal(secondary.remote_publish_proxy_jump, "jump.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loadWechatExtendConfig throws on invalid remote_publish_port", async (t) => {
|
||||||
|
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
|
||||||
|
const homeRoot = await makeTempDir("wechat-extend-home-");
|
||||||
|
|
||||||
|
useCwd(t, cwdRoot);
|
||||||
|
useHome(t, homeRoot);
|
||||||
|
useXdgConfigHome(t, undefined);
|
||||||
|
|
||||||
|
await writeExtendFile(
|
||||||
|
cwdRoot,
|
||||||
|
[
|
||||||
|
"remote_publish_host: example.com",
|
||||||
|
"remote_publish_port: 99999",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(() => loadWechatExtendConfig(), /Invalid remote_publish_port: 99999/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loadWechatExtendConfig throws on invalid remote_publish_connect_timeout", async (t) => {
|
||||||
|
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
|
||||||
|
const homeRoot = await makeTempDir("wechat-extend-home-");
|
||||||
|
|
||||||
|
useCwd(t, cwdRoot);
|
||||||
|
useHome(t, homeRoot);
|
||||||
|
useXdgConfigHome(t, undefined);
|
||||||
|
|
||||||
|
await writeExtendFile(
|
||||||
|
cwdRoot,
|
||||||
|
[
|
||||||
|
"remote_publish_host: example.com",
|
||||||
|
"remote_publish_connect_timeout: 0",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(() => loadWechatExtendConfig(), /Invalid remote_publish_connect_timeout: 0/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loadWechatExtendConfig throws on invalid remote_publish_strict_host_key_checking", async (t) => {
|
||||||
|
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
|
||||||
|
const homeRoot = await makeTempDir("wechat-extend-home-");
|
||||||
|
|
||||||
|
useCwd(t, cwdRoot);
|
||||||
|
useHome(t, homeRoot);
|
||||||
|
useXdgConfigHome(t, undefined);
|
||||||
|
|
||||||
|
await writeExtendFile(
|
||||||
|
cwdRoot,
|
||||||
|
[
|
||||||
|
"remote_publish_host: example.com",
|
||||||
|
"remote_publish_strict_host_key_checking: maybe",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => loadWechatExtendConfig(),
|
||||||
|
/Invalid remote_publish_strict_host_key_checking: maybe/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("loadCredentials reports skipped incomplete sources when no complete pair exists", async (t) => {
|
test("loadCredentials reports skipped incomplete sources when no complete pair exists", async (t) => {
|
||||||
const cwdRoot = await makeTempDir("wechat-creds-cwd-");
|
const cwdRoot = await makeTempDir("wechat-creds-cwd-");
|
||||||
const homeRoot = await makeTempDir("wechat-creds-home-");
|
const homeRoot = await makeTempDir("wechat-creds-home-");
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
|
||||||
|
export type StrictHostKeyChecking = "yes" | "no" | "accept-new";
|
||||||
|
|
||||||
export interface WechatAccount {
|
export interface WechatAccount {
|
||||||
name: string;
|
name: string;
|
||||||
alias: string;
|
alias: string;
|
||||||
@@ -13,6 +15,14 @@ export interface WechatAccount {
|
|||||||
app_id?: string;
|
app_id?: string;
|
||||||
app_secret?: string;
|
app_secret?: string;
|
||||||
chrome_profile_path?: string;
|
chrome_profile_path?: string;
|
||||||
|
remote_publish_host?: string;
|
||||||
|
remote_publish_user?: string;
|
||||||
|
remote_publish_port?: number;
|
||||||
|
remote_publish_identity_file?: string;
|
||||||
|
remote_publish_known_hosts_file?: string;
|
||||||
|
remote_publish_strict_host_key_checking?: StrictHostKeyChecking;
|
||||||
|
remote_publish_connect_timeout?: number;
|
||||||
|
remote_publish_proxy_jump?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WechatExtendConfig {
|
export interface WechatExtendConfig {
|
||||||
@@ -23,6 +33,14 @@ export interface WechatExtendConfig {
|
|||||||
need_open_comment?: number;
|
need_open_comment?: number;
|
||||||
only_fans_can_comment?: number;
|
only_fans_can_comment?: number;
|
||||||
chrome_profile_path?: string;
|
chrome_profile_path?: string;
|
||||||
|
remote_publish_host?: string;
|
||||||
|
remote_publish_user?: string;
|
||||||
|
remote_publish_port?: number;
|
||||||
|
remote_publish_identity_file?: string;
|
||||||
|
remote_publish_known_hosts_file?: string;
|
||||||
|
remote_publish_strict_host_key_checking?: StrictHostKeyChecking;
|
||||||
|
remote_publish_connect_timeout?: number;
|
||||||
|
remote_publish_proxy_jump?: string;
|
||||||
accounts?: WechatAccount[];
|
accounts?: WechatAccount[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +54,14 @@ export interface ResolvedAccount {
|
|||||||
app_id?: string;
|
app_id?: string;
|
||||||
app_secret?: string;
|
app_secret?: string;
|
||||||
chrome_profile_path?: string;
|
chrome_profile_path?: string;
|
||||||
|
remote_publish_host?: string;
|
||||||
|
remote_publish_user?: string;
|
||||||
|
remote_publish_port?: number;
|
||||||
|
remote_publish_identity_file?: string;
|
||||||
|
remote_publish_known_hosts_file?: string;
|
||||||
|
remote_publish_strict_host_key_checking?: StrictHostKeyChecking;
|
||||||
|
remote_publish_connect_timeout?: number;
|
||||||
|
remote_publish_proxy_jump?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripQuotes(s: string): string {
|
function stripQuotes(s: string): string {
|
||||||
@@ -46,6 +72,34 @@ function toBool01(v: string): number {
|
|||||||
return v === "1" || v === "true" ? 1 : 0;
|
return v === "1" || v === "true" ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function homeDir(): string {
|
||||||
|
return process.env.HOME || process.env.USERPROFILE || os.homedir();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePort(key: string, v: string): number {
|
||||||
|
const n = Number.parseInt(v, 10);
|
||||||
|
if (!Number.isFinite(n) || String(n) !== v.trim() || n < 1 || n > 65535) {
|
||||||
|
throw new Error(`Invalid ${key}: ${v} (expected integer 1-65535)`);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveInt(key: string, v: string): number {
|
||||||
|
const n = Number.parseInt(v, 10);
|
||||||
|
if (!Number.isFinite(n) || String(n) !== v.trim() || n <= 0) {
|
||||||
|
throw new Error(`Invalid ${key}: ${v} (expected positive integer)`);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStrictHostKeyChecking(key: string, v: string): StrictHostKeyChecking {
|
||||||
|
const lower = v.toLowerCase();
|
||||||
|
if (lower === "yes" || lower === "no" || lower === "accept-new") {
|
||||||
|
return lower;
|
||||||
|
}
|
||||||
|
throw new Error(`Invalid ${key}: ${v} (expected yes|no|accept-new)`);
|
||||||
|
}
|
||||||
|
|
||||||
function parseWechatExtend(content: string): WechatExtendConfig {
|
function parseWechatExtend(content: string): WechatExtendConfig {
|
||||||
const config: WechatExtendConfig = {};
|
const config: WechatExtendConfig = {};
|
||||||
const lines = content.split("\n");
|
const lines = content.split("\n");
|
||||||
@@ -106,6 +160,14 @@ function parseWechatExtend(content: string): WechatExtendConfig {
|
|||||||
case "need_open_comment": config.need_open_comment = toBool01(val); break;
|
case "need_open_comment": config.need_open_comment = toBool01(val); break;
|
||||||
case "only_fans_can_comment": config.only_fans_can_comment = toBool01(val); break;
|
case "only_fans_can_comment": config.only_fans_can_comment = toBool01(val); break;
|
||||||
case "chrome_profile_path": config.chrome_profile_path = val; break;
|
case "chrome_profile_path": config.chrome_profile_path = val; break;
|
||||||
|
case "remote_publish_host": config.remote_publish_host = val; break;
|
||||||
|
case "remote_publish_user": config.remote_publish_user = val; break;
|
||||||
|
case "remote_publish_port": config.remote_publish_port = parsePort("remote_publish_port", val); break;
|
||||||
|
case "remote_publish_identity_file": config.remote_publish_identity_file = val; break;
|
||||||
|
case "remote_publish_known_hosts_file": config.remote_publish_known_hosts_file = val; break;
|
||||||
|
case "remote_publish_strict_host_key_checking": config.remote_publish_strict_host_key_checking = parseStrictHostKeyChecking("remote_publish_strict_host_key_checking", val); break;
|
||||||
|
case "remote_publish_connect_timeout": config.remote_publish_connect_timeout = parsePositiveInt("remote_publish_connect_timeout", val); break;
|
||||||
|
case "remote_publish_proxy_jump": config.remote_publish_proxy_jump = val; break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +185,18 @@ function parseWechatExtend(content: string): WechatExtendConfig {
|
|||||||
app_id: a.app_id || undefined,
|
app_id: a.app_id || undefined,
|
||||||
app_secret: a.app_secret || undefined,
|
app_secret: a.app_secret || undefined,
|
||||||
chrome_profile_path: a.chrome_profile_path || undefined,
|
chrome_profile_path: a.chrome_profile_path || undefined,
|
||||||
|
remote_publish_host: a.remote_publish_host || undefined,
|
||||||
|
remote_publish_user: a.remote_publish_user || undefined,
|
||||||
|
remote_publish_port: a.remote_publish_port ? parsePort("remote_publish_port", a.remote_publish_port) : undefined,
|
||||||
|
remote_publish_identity_file: a.remote_publish_identity_file || undefined,
|
||||||
|
remote_publish_known_hosts_file: a.remote_publish_known_hosts_file || undefined,
|
||||||
|
remote_publish_strict_host_key_checking: a.remote_publish_strict_host_key_checking
|
||||||
|
? parseStrictHostKeyChecking("remote_publish_strict_host_key_checking", a.remote_publish_strict_host_key_checking)
|
||||||
|
: undefined,
|
||||||
|
remote_publish_connect_timeout: a.remote_publish_connect_timeout
|
||||||
|
? parsePositiveInt("remote_publish_connect_timeout", a.remote_publish_connect_timeout)
|
||||||
|
: undefined,
|
||||||
|
remote_publish_proxy_jump: a.remote_publish_proxy_jump || undefined,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,18 +207,19 @@ export function loadWechatExtendConfig(): WechatExtendConfig {
|
|||||||
const paths = [
|
const paths = [
|
||||||
path.join(process.cwd(), ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"),
|
path.join(process.cwd(), ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"),
|
||||||
path.join(
|
path.join(
|
||||||
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"),
|
process.env.XDG_CONFIG_HOME || path.join(homeDir(), ".config"),
|
||||||
"baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"
|
"baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"
|
||||||
),
|
),
|
||||||
path.join(os.homedir(), ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"),
|
path.join(homeDir(), ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"),
|
||||||
];
|
];
|
||||||
for (const p of paths) {
|
for (const p of paths) {
|
||||||
|
let content: string;
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(p, "utf-8");
|
content = fs.readFileSync(p, "utf-8");
|
||||||
return parseWechatExtend(content);
|
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
return parseWechatExtend(content);
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -168,6 +243,15 @@ export function resolveAccount(config: WechatExtendConfig, alias?: string): Reso
|
|||||||
app_id: acct?.app_id,
|
app_id: acct?.app_id,
|
||||||
app_secret: acct?.app_secret,
|
app_secret: acct?.app_secret,
|
||||||
chrome_profile_path: acct?.chrome_profile_path ?? config.chrome_profile_path,
|
chrome_profile_path: acct?.chrome_profile_path ?? config.chrome_profile_path,
|
||||||
|
remote_publish_host: acct?.remote_publish_host ?? config.remote_publish_host,
|
||||||
|
remote_publish_user: acct?.remote_publish_user ?? config.remote_publish_user,
|
||||||
|
remote_publish_port: acct?.remote_publish_port ?? config.remote_publish_port,
|
||||||
|
remote_publish_identity_file: acct?.remote_publish_identity_file ?? config.remote_publish_identity_file,
|
||||||
|
remote_publish_known_hosts_file: acct?.remote_publish_known_hosts_file ?? config.remote_publish_known_hosts_file,
|
||||||
|
remote_publish_strict_host_key_checking:
|
||||||
|
acct?.remote_publish_strict_host_key_checking ?? config.remote_publish_strict_host_key_checking,
|
||||||
|
remote_publish_connect_timeout: acct?.remote_publish_connect_timeout ?? config.remote_publish_connect_timeout,
|
||||||
|
remote_publish_proxy_jump: acct?.remote_publish_proxy_jump ?? config.remote_publish_proxy_jump,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +357,7 @@ function resolveCredentialSource(
|
|||||||
|
|
||||||
export function loadCredentials(account?: ResolvedAccount): LoadedCredentials {
|
export function loadCredentials(account?: ResolvedAccount): LoadedCredentials {
|
||||||
const cwdEnvPath = path.join(process.cwd(), ".baoyu-skills", ".env");
|
const cwdEnvPath = path.join(process.cwd(), ".baoyu-skills", ".env");
|
||||||
const homeEnvPath = path.join(os.homedir(), ".baoyu-skills", ".env");
|
const homeEnvPath = path.join(homeDir(), ".baoyu-skills", ".env");
|
||||||
const cwdEnv = loadEnvFile(cwdEnvPath);
|
const cwdEnv = loadEnvFile(cwdEnvPath);
|
||||||
const homeEnv = loadEnvFile(homeEnvPath);
|
const homeEnv = loadEnvFile(homeEnvPath);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import http from "node:http";
|
||||||
|
import test, { type TestContext } from "node:test";
|
||||||
|
|
||||||
|
import { buildMultipart, wechatHttp } from "./wechat-http.ts";
|
||||||
|
|
||||||
|
interface ReceivedRequest {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
headers: http.IncomingHttpHeaders;
|
||||||
|
body: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startEchoServer(t: TestContext): Promise<{ baseUrl: string; received: ReceivedRequest[] }> {
|
||||||
|
const received: ReceivedRequest[] = [];
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
req.on("end", () => {
|
||||||
|
received.push({
|
||||||
|
method: req.method ?? "",
|
||||||
|
url: req.url ?? "",
|
||||||
|
headers: req.headers,
|
||||||
|
body: Buffer.concat(chunks),
|
||||||
|
});
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ ok: true, echo: { url: req.url, method: req.method } }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("Failed to start echo server");
|
||||||
|
}
|
||||||
|
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||||
|
|
||||||
|
t.after(async () => {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
return { baseUrl, received };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("wechatHttp performs a GET and passes through query string", async (t) => {
|
||||||
|
const { baseUrl, received } = await startEchoServer(t);
|
||||||
|
const res = await wechatHttp(`${baseUrl}/cgi-bin/token?grant_type=client_credential&appid=AID`);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const data = await res.json<{ ok: boolean; echo: { url: string; method: string } }>();
|
||||||
|
assert.equal(data.ok, true);
|
||||||
|
assert.equal(received.length, 1);
|
||||||
|
assert.equal(received[0]!.method, "GET");
|
||||||
|
assert.equal(received[0]!.url, "/cgi-bin/token?grant_type=client_credential&appid=AID");
|
||||||
|
assert.equal(received[0]!.body.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wechatHttp POST sends JSON body with content-length header", async (t) => {
|
||||||
|
const { baseUrl, received } = await startEchoServer(t);
|
||||||
|
const body = JSON.stringify({ articles: [{ title: "hi" }] });
|
||||||
|
const res = await wechatHttp(`${baseUrl}/cgi-bin/draft/add?access_token=T`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(received.length, 1);
|
||||||
|
assert.equal(received[0]!.method, "POST");
|
||||||
|
assert.equal(received[0]!.headers["content-type"], "application/json");
|
||||||
|
assert.equal(received[0]!.headers["content-length"], String(Buffer.byteLength(body)));
|
||||||
|
assert.equal(received[0]!.body.toString("utf-8"), body);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wechatHttp .text() returns the raw response body", async (t) => {
|
||||||
|
const { baseUrl } = await startEchoServer(t);
|
||||||
|
const res = await wechatHttp(`${baseUrl}/hello`);
|
||||||
|
const text = await res.text();
|
||||||
|
assert.match(text, /"ok":true/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wechatHttp .buffer() returns a Buffer of the response body", async (t) => {
|
||||||
|
const { baseUrl } = await startEchoServer(t);
|
||||||
|
const res = await wechatHttp(`${baseUrl}/`);
|
||||||
|
const buf = await res.buffer();
|
||||||
|
assert.ok(Buffer.isBuffer(buf));
|
||||||
|
assert.ok(buf.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildMultipart produces a parsable multipart payload", () => {
|
||||||
|
const fileData = Buffer.from("ZZZ");
|
||||||
|
const { contentType, body } = buildMultipart([
|
||||||
|
{
|
||||||
|
name: "media",
|
||||||
|
filename: "image.png",
|
||||||
|
contentType: "image/png",
|
||||||
|
data: fileData,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const boundaryMatch = contentType.match(/^multipart\/form-data; boundary=(.+)$/);
|
||||||
|
assert.ok(boundaryMatch, `expected boundary in Content-Type, got ${contentType}`);
|
||||||
|
const boundary = boundaryMatch![1]!;
|
||||||
|
const text = body.toString("binary");
|
||||||
|
assert.ok(text.startsWith(`--${boundary}\r\n`), "body must start with opening boundary");
|
||||||
|
assert.ok(
|
||||||
|
text.endsWith(`--${boundary}--\r\n`),
|
||||||
|
"body must end with closing boundary",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
text,
|
||||||
|
/Content-Disposition: form-data; name="media"; filename="image\.png"\r\n/,
|
||||||
|
);
|
||||||
|
assert.match(text, /Content-Type: image\/png\r\n/);
|
||||||
|
// The raw file bytes must appear verbatim after a blank line.
|
||||||
|
assert.ok(
|
||||||
|
text.includes("\r\n\r\nZZZ\r\n"),
|
||||||
|
"body must contain the raw file bytes after the part headers",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wechatHttp accepts a multipart body produced by buildMultipart", async (t) => {
|
||||||
|
const { baseUrl, received } = await startEchoServer(t);
|
||||||
|
const fileData = Buffer.from("HELLO");
|
||||||
|
const multipart = buildMultipart([
|
||||||
|
{ name: "media", filename: "x.png", contentType: "image/png", data: fileData },
|
||||||
|
]);
|
||||||
|
const res = await wechatHttp(`${baseUrl}/cgi-bin/media/uploadimg?access_token=T`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": multipart.contentType },
|
||||||
|
body: multipart.body,
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(received.length, 1);
|
||||||
|
assert.equal(received[0]!.method, "POST");
|
||||||
|
assert.match(received[0]!.headers["content-type"]!, /^multipart\/form-data; boundary=/);
|
||||||
|
assert.ok(received[0]!.body.includes(Buffer.from("HELLO")));
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
export interface WechatHttpInit {
|
||||||
|
method?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string | Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WechatHttpResponse {
|
||||||
|
status: number;
|
||||||
|
statusText: string;
|
||||||
|
headers: Record<string, string | string[] | undefined>;
|
||||||
|
buffer(): Promise<Buffer>;
|
||||||
|
text(): Promise<string>;
|
||||||
|
json<T = unknown>(): Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WechatClient = (
|
||||||
|
url: string,
|
||||||
|
init?: WechatHttpInit,
|
||||||
|
) => Promise<WechatHttpResponse>;
|
||||||
|
|
||||||
|
export interface MultipartFilePart {
|
||||||
|
name: string;
|
||||||
|
filename: string;
|
||||||
|
contentType: string;
|
||||||
|
data: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MultipartBody {
|
||||||
|
contentType: string;
|
||||||
|
body: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMultipart(parts: MultipartFilePart[]): MultipartBody {
|
||||||
|
const boundary = `----WebKitFormBoundary${Date.now().toString(16)}${Math.random().toString(16).slice(2, 10)}`;
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const header =
|
||||||
|
`--${boundary}\r\n` +
|
||||||
|
`Content-Disposition: form-data; name="${part.name}"; filename="${part.filename}"\r\n` +
|
||||||
|
`Content-Type: ${part.contentType}\r\n\r\n`;
|
||||||
|
chunks.push(Buffer.from(header, "utf-8"));
|
||||||
|
chunks.push(part.data);
|
||||||
|
chunks.push(Buffer.from("\r\n", "utf-8"));
|
||||||
|
}
|
||||||
|
chunks.push(Buffer.from(`--${boundary}--\r\n`, "utf-8"));
|
||||||
|
|
||||||
|
return {
|
||||||
|
contentType: `multipart/form-data; boundary=${boundary}`,
|
||||||
|
body: Buffer.concat(chunks),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function headersToRecord(headers: Headers): Record<string, string | string[] | undefined> {
|
||||||
|
const out: Record<string, string | string[] | undefined> = {};
|
||||||
|
headers.forEach((value, key) => {
|
||||||
|
const existing = out[key];
|
||||||
|
if (existing === undefined) {
|
||||||
|
out[key] = value;
|
||||||
|
} else if (Array.isArray(existing)) {
|
||||||
|
existing.push(value);
|
||||||
|
} else {
|
||||||
|
out[key] = [existing, value];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const wechatHttp: WechatClient = async (url, init = {}) => {
|
||||||
|
const method = init.method ?? (init.body !== undefined ? "POST" : "GET");
|
||||||
|
const headers: Record<string, string> = { ...(init.headers ?? {}) };
|
||||||
|
|
||||||
|
let body: BodyInit | undefined;
|
||||||
|
if (init.body !== undefined) {
|
||||||
|
body = Buffer.isBuffer(init.body)
|
||||||
|
? new Uint8Array(init.body.buffer, init.body.byteOffset, init.body.byteLength)
|
||||||
|
: init.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, { method, headers, body });
|
||||||
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
headers: headersToRecord(res.headers),
|
||||||
|
async buffer() {
|
||||||
|
return buf;
|
||||||
|
},
|
||||||
|
async text() {
|
||||||
|
return buf.toString("utf-8");
|
||||||
|
},
|
||||||
|
async json<T = unknown>() {
|
||||||
|
return JSON.parse(buf.toString("utf-8")) as T;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
type WechatUploadAsset,
|
||||||
|
detectImageFormatFromBuffer,
|
||||||
|
} from "./wechat-image-processor.ts";
|
||||||
|
|
||||||
|
export type { WechatUploadAsset };
|
||||||
|
|
||||||
|
const MIME_TYPES_BY_EXT: Record<string, string> = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".bmp": "image/bmp",
|
||||||
|
".tiff": "image/tiff",
|
||||||
|
".tif": "image/tiff",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadUploadAsset(
|
||||||
|
imagePath: string,
|
||||||
|
baseDir?: string,
|
||||||
|
): Promise<WechatUploadAsset> {
|
||||||
|
let fileBuffer: Buffer;
|
||||||
|
let filename: string;
|
||||||
|
let contentType: string;
|
||||||
|
let fileSize = 0;
|
||||||
|
let fileExt = "";
|
||||||
|
|
||||||
|
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
|
||||||
|
const response = await fetch(imagePath);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to download image: ${imagePath}`);
|
||||||
|
}
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
if (buffer.byteLength === 0) {
|
||||||
|
throw new Error(`Remote image is empty: ${imagePath}`);
|
||||||
|
}
|
||||||
|
fileBuffer = Buffer.from(buffer);
|
||||||
|
fileSize = buffer.byteLength;
|
||||||
|
const urlPath = imagePath.split("?")[0]!;
|
||||||
|
filename = path.basename(urlPath) || "image.jpg";
|
||||||
|
fileExt = path.extname(filename).toLowerCase();
|
||||||
|
contentType = response.headers.get("content-type") || "image/jpeg";
|
||||||
|
} else {
|
||||||
|
const resolvedPath = path.isAbsolute(imagePath)
|
||||||
|
? imagePath
|
||||||
|
: path.resolve(baseDir || process.cwd(), imagePath);
|
||||||
|
|
||||||
|
if (!fs.existsSync(resolvedPath)) {
|
||||||
|
throw new Error(`Image not found: ${resolvedPath}`);
|
||||||
|
}
|
||||||
|
const stats = fs.statSync(resolvedPath);
|
||||||
|
if (stats.size === 0) {
|
||||||
|
throw new Error(`Local image is empty: ${resolvedPath}`);
|
||||||
|
}
|
||||||
|
fileSize = stats.size;
|
||||||
|
fileBuffer = fs.readFileSync(resolvedPath);
|
||||||
|
filename = path.basename(resolvedPath);
|
||||||
|
fileExt = path.extname(filename).toLowerCase();
|
||||||
|
contentType = MIME_TYPES_BY_EXT[fileExt] || "image/jpeg";
|
||||||
|
}
|
||||||
|
|
||||||
|
const detected = detectImageFormatFromBuffer(fileBuffer);
|
||||||
|
if (detected && detected.contentType !== contentType) {
|
||||||
|
console.error(`[wechat-api] Format mismatch: ${filename} declared as ${contentType}, actual ${detected.contentType}`);
|
||||||
|
contentType = detected.contentType;
|
||||||
|
fileExt = detected.fileExt;
|
||||||
|
filename = `${path.basename(filename, path.extname(filename))}${detected.fileExt}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer: fileBuffer,
|
||||||
|
filename,
|
||||||
|
contentType,
|
||||||
|
fileExt,
|
||||||
|
fileSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import net from "node:net";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildSshArgs,
|
||||||
|
findFreePort,
|
||||||
|
normalizeRemoteConfig,
|
||||||
|
} from "./wechat-remote-publish.ts";
|
||||||
|
|
||||||
|
test("normalizeRemoteConfig requires a host", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeRemoteConfig({ host: "" }),
|
||||||
|
/Remote publish host is required/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeRemoteConfig({ host: " " }),
|
||||||
|
/Remote publish host is required/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeRemoteConfig applies user/port defaults and trims host", () => {
|
||||||
|
const result = normalizeRemoteConfig({ host: " example.com " });
|
||||||
|
assert.equal(result.host, "example.com");
|
||||||
|
assert.equal(result.user, "root");
|
||||||
|
assert.equal(result.port, 22);
|
||||||
|
assert.equal(result.identityFile, undefined);
|
||||||
|
assert.equal(result.knownHostsFile, undefined);
|
||||||
|
assert.equal(result.strictHostKeyChecking, undefined);
|
||||||
|
assert.equal(result.connectTimeout, undefined);
|
||||||
|
assert.equal(result.proxyJump, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeRemoteConfig preserves explicit user, port, and SSH options", () => {
|
||||||
|
const result = normalizeRemoteConfig({
|
||||||
|
host: "example.com",
|
||||||
|
user: "deploy",
|
||||||
|
port: 2222,
|
||||||
|
identityFile: "/home/me/.ssh/id_ed25519",
|
||||||
|
knownHostsFile: "/home/me/.ssh/known_hosts",
|
||||||
|
strictHostKeyChecking: "accept-new",
|
||||||
|
connectTimeout: 15,
|
||||||
|
proxyJump: "bastion.example.com",
|
||||||
|
});
|
||||||
|
assert.equal(result.user, "deploy");
|
||||||
|
assert.equal(result.port, 2222);
|
||||||
|
assert.equal(result.identityFile, "/home/me/.ssh/id_ed25519");
|
||||||
|
assert.equal(result.knownHostsFile, "/home/me/.ssh/known_hosts");
|
||||||
|
assert.equal(result.strictHostKeyChecking, "accept-new");
|
||||||
|
assert.equal(result.connectTimeout, 15);
|
||||||
|
assert.equal(result.proxyJump, "bastion.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeRemoteConfig rejects invalid port", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeRemoteConfig({ host: "example.com", port: 0 }),
|
||||||
|
/Invalid remote publish port/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeRemoteConfig({ host: "example.com", port: 65536 }),
|
||||||
|
/Invalid remote publish port/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeRemoteConfig({ host: "example.com", port: 1.5 }),
|
||||||
|
/Invalid remote publish port/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeRemoteConfig rejects invalid connect timeout", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeRemoteConfig({ host: "example.com", connectTimeout: 0 }),
|
||||||
|
/Invalid remote_publish_connect_timeout/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeRemoteConfig({ host: "example.com", connectTimeout: -3 }),
|
||||||
|
/Invalid remote_publish_connect_timeout/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeRemoteConfig rejects invalid strict host key checking", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
normalizeRemoteConfig({
|
||||||
|
host: "example.com",
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
strictHostKeyChecking: "maybe" as any,
|
||||||
|
}),
|
||||||
|
/Invalid remote_publish_strict_host_key_checking/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeRemoteConfig falls back to default user when blank string provided", () => {
|
||||||
|
const result = normalizeRemoteConfig({ host: "example.com", user: " " });
|
||||||
|
assert.equal(result.user, "root");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildSshArgs emits the whitelisted minimum set", () => {
|
||||||
|
const args = buildSshArgs(
|
||||||
|
{ host: "example.com", user: "root", port: 22 },
|
||||||
|
1080,
|
||||||
|
);
|
||||||
|
assert.deepEqual(args, [
|
||||||
|
"-N",
|
||||||
|
"-T",
|
||||||
|
"-D", "127.0.0.1:1080",
|
||||||
|
"-o", "ExitOnForwardFailure=yes",
|
||||||
|
"-o", "ServerAliveInterval=30",
|
||||||
|
"-o", "ServerAliveCountMax=3",
|
||||||
|
"-p", "22",
|
||||||
|
"root@example.com",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildSshArgs threads optional ssh options in stable order", () => {
|
||||||
|
const args = buildSshArgs(
|
||||||
|
{
|
||||||
|
host: "example.com",
|
||||||
|
user: "deploy",
|
||||||
|
port: 2222,
|
||||||
|
identityFile: "/p/id_ed25519",
|
||||||
|
knownHostsFile: "/p/known_hosts",
|
||||||
|
strictHostKeyChecking: "accept-new",
|
||||||
|
connectTimeout: 12,
|
||||||
|
proxyJump: "bastion.example.com",
|
||||||
|
},
|
||||||
|
1080,
|
||||||
|
);
|
||||||
|
assert.deepEqual(args, [
|
||||||
|
"-N",
|
||||||
|
"-T",
|
||||||
|
"-D", "127.0.0.1:1080",
|
||||||
|
"-o", "ExitOnForwardFailure=yes",
|
||||||
|
"-o", "ServerAliveInterval=30",
|
||||||
|
"-o", "ServerAliveCountMax=3",
|
||||||
|
"-p", "2222",
|
||||||
|
"-i", "/p/id_ed25519",
|
||||||
|
"-o", "UserKnownHostsFile=/p/known_hosts",
|
||||||
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "ConnectTimeout=12",
|
||||||
|
"-J", "bastion.example.com",
|
||||||
|
"deploy@example.com",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildSshArgs does not emit raw ssh options for unknown fields", () => {
|
||||||
|
const args = buildSshArgs(
|
||||||
|
{
|
||||||
|
host: "example.com",
|
||||||
|
user: "root",
|
||||||
|
port: 22,
|
||||||
|
// No extra unknown keys are accepted — typed config is the whitelist.
|
||||||
|
},
|
||||||
|
1080,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
args.filter((a) => a === "-o" || a.startsWith("--")).length,
|
||||||
|
3, // ExitOnForwardFailure, ServerAliveInterval, ServerAliveCountMax (and only those)
|
||||||
|
"buildSshArgs must only emit the three baseline -o options when no extras given",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildSshArgs rejects invalid SOCKS port", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => buildSshArgs({ host: "example.com", user: "root", port: 22 }, 0),
|
||||||
|
/Invalid SOCKS port/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => buildSshArgs({ host: "example.com", user: "root", port: 22 }, 70_000),
|
||||||
|
/Invalid SOCKS port/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findFreePort returns a usable loopback port", async () => {
|
||||||
|
const port = await findFreePort();
|
||||||
|
assert.ok(port > 0 && port < 65536, `expected valid port, got ${port}`);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.unref();
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(port, "127.0.0.1", () => {
|
||||||
|
server.close((err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
||||||
|
import net from "node:net";
|
||||||
|
import type { Readable } from "node:stream";
|
||||||
|
|
||||||
|
import type { StrictHostKeyChecking } from "./wechat-extend-config.ts";
|
||||||
|
import type { WechatClient } from "./wechat-http.ts";
|
||||||
|
import { createSocksClient } from "./wechat-socks-http.ts";
|
||||||
|
|
||||||
|
export interface RemotePublishConfig {
|
||||||
|
host: string;
|
||||||
|
user?: string;
|
||||||
|
port?: number;
|
||||||
|
identityFile?: string;
|
||||||
|
knownHostsFile?: string;
|
||||||
|
strictHostKeyChecking?: StrictHostKeyChecking;
|
||||||
|
connectTimeout?: number;
|
||||||
|
proxyJump?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedRemotePublishConfig {
|
||||||
|
host: string;
|
||||||
|
user: string;
|
||||||
|
port: number;
|
||||||
|
identityFile?: string;
|
||||||
|
knownHostsFile?: string;
|
||||||
|
strictHostKeyChecking?: StrictHostKeyChecking;
|
||||||
|
connectTimeout?: number;
|
||||||
|
proxyJump?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SshTunnel {
|
||||||
|
port: number;
|
||||||
|
client: WechatClient;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartSshTunnelOptions {
|
||||||
|
readyTimeoutMs?: number;
|
||||||
|
killTimeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_USER = "root";
|
||||||
|
const DEFAULT_PORT = 22;
|
||||||
|
const DEFAULT_READY_TIMEOUT_MS = 10_000;
|
||||||
|
const DEFAULT_KILL_TIMEOUT_MS = 3_000;
|
||||||
|
const SSH_LOOPBACK_HOST = "127.0.0.1";
|
||||||
|
|
||||||
|
export function normalizeRemoteConfig(config: RemotePublishConfig): NormalizedRemotePublishConfig {
|
||||||
|
if (!config.host || !config.host.trim()) {
|
||||||
|
throw new Error("Remote publish host is required (set remote_publish_host or --remote-host).");
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = config.port ?? DEFAULT_PORT;
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||||
|
throw new Error(`Invalid remote publish port: ${config.port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.connectTimeout !== undefined) {
|
||||||
|
if (!Number.isInteger(config.connectTimeout) || config.connectTimeout <= 0) {
|
||||||
|
throw new Error(`Invalid remote_publish_connect_timeout: ${config.connectTimeout}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
config.strictHostKeyChecking !== undefined &&
|
||||||
|
config.strictHostKeyChecking !== "yes" &&
|
||||||
|
config.strictHostKeyChecking !== "no" &&
|
||||||
|
config.strictHostKeyChecking !== "accept-new"
|
||||||
|
) {
|
||||||
|
throw new Error(`Invalid remote_publish_strict_host_key_checking: ${config.strictHostKeyChecking}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
host: config.host.trim(),
|
||||||
|
user: (config.user ?? DEFAULT_USER).trim() || DEFAULT_USER,
|
||||||
|
port,
|
||||||
|
identityFile: config.identityFile,
|
||||||
|
knownHostsFile: config.knownHostsFile,
|
||||||
|
strictHostKeyChecking: config.strictHostKeyChecking,
|
||||||
|
connectTimeout: config.connectTimeout,
|
||||||
|
proxyJump: config.proxyJump,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSshArgs(config: NormalizedRemotePublishConfig, socksPort: number): string[] {
|
||||||
|
if (!Number.isInteger(socksPort) || socksPort < 1 || socksPort > 65535) {
|
||||||
|
throw new Error(`Invalid SOCKS port: ${socksPort}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const args: string[] = [
|
||||||
|
"-N",
|
||||||
|
"-T",
|
||||||
|
"-D", `${SSH_LOOPBACK_HOST}:${socksPort}`,
|
||||||
|
"-o", "ExitOnForwardFailure=yes",
|
||||||
|
"-o", "ServerAliveInterval=30",
|
||||||
|
"-o", "ServerAliveCountMax=3",
|
||||||
|
"-p", String(config.port),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (config.identityFile) {
|
||||||
|
args.push("-i", config.identityFile);
|
||||||
|
}
|
||||||
|
if (config.knownHostsFile) {
|
||||||
|
args.push("-o", `UserKnownHostsFile=${config.knownHostsFile}`);
|
||||||
|
}
|
||||||
|
if (config.strictHostKeyChecking) {
|
||||||
|
args.push("-o", `StrictHostKeyChecking=${config.strictHostKeyChecking}`);
|
||||||
|
}
|
||||||
|
if (config.connectTimeout !== undefined) {
|
||||||
|
args.push("-o", `ConnectTimeout=${config.connectTimeout}`);
|
||||||
|
}
|
||||||
|
if (config.proxyJump) {
|
||||||
|
args.push("-J", config.proxyJump);
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push(`${config.user}@${config.host}`);
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findFreePort(): Promise<number> {
|
||||||
|
return new Promise<number>((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.unref();
|
||||||
|
server.on("error", reject);
|
||||||
|
server.listen(0, SSH_LOOPBACK_HOST, () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
server.close(() => reject(new Error("Failed to acquire free port")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const port = address.port;
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForSocksReady(port: number, timeoutMs: number): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
let lastError: unknown = undefined;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
await tryConnect(port);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
await sleep(150);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`SOCKS proxy on ${SSH_LOOPBACK_HOST}:${port} not ready within ${timeoutMs}ms${lastError ? `: ${(lastError as Error).message}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryConnect(port: number): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = net.connect({ host: SSH_LOOPBACK_HOST, port });
|
||||||
|
socket.once("connect", () => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
socket.once("error", (err) => {
|
||||||
|
socket.destroy();
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startSshTunnel(
|
||||||
|
config: NormalizedRemotePublishConfig,
|
||||||
|
options: StartSshTunnelOptions = {},
|
||||||
|
): Promise<SshTunnel> {
|
||||||
|
const readyTimeout = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
|
||||||
|
const killTimeout = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
|
||||||
|
|
||||||
|
const port = await findFreePort();
|
||||||
|
const args = buildSshArgs(config, port);
|
||||||
|
|
||||||
|
console.error(`[wechat-remote-publish] Starting SSH SOCKS5 tunnel: ssh ${args.join(" ")}`);
|
||||||
|
const child = spawn("ssh", args, {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
}) as ChildProcessByStdio<null, Readable, Readable>;
|
||||||
|
|
||||||
|
const stderrChunks: string[] = [];
|
||||||
|
child.stderr.on("data", (chunk: Buffer) => {
|
||||||
|
stderrChunks.push(chunk.toString("utf-8"));
|
||||||
|
});
|
||||||
|
|
||||||
|
let earlyExit: { code: number | null; signal: NodeJS.Signals | null } | undefined;
|
||||||
|
child.once("exit", (code, signal) => {
|
||||||
|
earlyExit = { code, signal };
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitForSocksReady(port, readyTimeout);
|
||||||
|
} catch (err) {
|
||||||
|
await killChild(child, killTimeout);
|
||||||
|
const stderrTail = stderrChunks.join("").trim().split("\n").slice(-5).join("\n");
|
||||||
|
const suffix = stderrTail ? `\nssh stderr (tail):\n${stderrTail}` : "";
|
||||||
|
const exitSuffix = earlyExit
|
||||||
|
? `\nssh exited early with code=${earlyExit.code} signal=${earlyExit.signal}`
|
||||||
|
: "";
|
||||||
|
throw new Error(`${(err as Error).message}${exitSuffix}${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createSocksClient({ host: SSH_LOOPBACK_HOST, port });
|
||||||
|
|
||||||
|
const signalHandlers: Array<{ signal: NodeJS.Signals; handler: () => void }> = [];
|
||||||
|
let closed = false;
|
||||||
|
const close = async (): Promise<void> => {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
for (const { signal, handler } of signalHandlers) {
|
||||||
|
process.off(signal, handler);
|
||||||
|
}
|
||||||
|
await killChild(child, killTimeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
||||||
|
const handler = () => {
|
||||||
|
void close();
|
||||||
|
};
|
||||||
|
process.once(signal, handler);
|
||||||
|
signalHandlers.push({ signal, handler });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { port, client, close };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function killChild(child: ChildProcessByStdio<null, Readable, Readable>, killTimeoutMs: number): Promise<void> {
|
||||||
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const exited = new Promise<void>((resolve) => {
|
||||||
|
child.once("exit", () => resolve());
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
} catch {
|
||||||
|
/* already dead */
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
} catch {
|
||||||
|
/* already dead */
|
||||||
|
}
|
||||||
|
}, killTimeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await exited;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withSshTunnel<T>(
|
||||||
|
config: NormalizedRemotePublishConfig,
|
||||||
|
fn: (client: WechatClient) => Promise<T>,
|
||||||
|
options?: StartSshTunnelOptions,
|
||||||
|
): Promise<T> {
|
||||||
|
const tunnel = await startSshTunnel(config, options);
|
||||||
|
try {
|
||||||
|
return await fn(tunnel.client);
|
||||||
|
} finally {
|
||||||
|
await tunnel.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import http from "node:http";
|
||||||
|
import net from "node:net";
|
||||||
|
import test, { type TestContext } from "node:test";
|
||||||
|
|
||||||
|
import { createSocksClient } from "./wechat-socks-http.ts";
|
||||||
|
|
||||||
|
interface EchoServer {
|
||||||
|
baseUrl: string;
|
||||||
|
port: number;
|
||||||
|
received: Array<{ method: string; url: string; headers: http.IncomingHttpHeaders; body: Buffer }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startEchoServer(t: TestContext): Promise<EchoServer> {
|
||||||
|
const received: EchoServer["received"] = [];
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
req.on("end", () => {
|
||||||
|
received.push({
|
||||||
|
method: req.method ?? "",
|
||||||
|
url: req.url ?? "",
|
||||||
|
headers: req.headers,
|
||||||
|
body: Buffer.concat(chunks),
|
||||||
|
});
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ ok: true, url: req.url }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") throw new Error("echo server bind failed");
|
||||||
|
const port = address.port;
|
||||||
|
t.after(async () => {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
});
|
||||||
|
return { baseUrl: `http://127.0.0.1:${port}`, port, received };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FakeSocks5 {
|
||||||
|
port: number;
|
||||||
|
connectionCount: () => number;
|
||||||
|
destinations: () => Array<{ host: string; port: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startFakeSocks5(t: TestContext): Promise<FakeSocks5> {
|
||||||
|
let connectionCount = 0;
|
||||||
|
const destinations: Array<{ host: string; port: number }> = [];
|
||||||
|
|
||||||
|
const server = net.createServer((client) => {
|
||||||
|
connectionCount++;
|
||||||
|
let phase: "greeting" | "request" | "tunnel" = "greeting";
|
||||||
|
let buf = Buffer.alloc(0);
|
||||||
|
let upstream: net.Socket | undefined;
|
||||||
|
|
||||||
|
const tryParse = () => {
|
||||||
|
if (phase === "greeting") {
|
||||||
|
if (buf.length < 2) return;
|
||||||
|
const nMethods = buf[1]!;
|
||||||
|
if (buf.length < 2 + nMethods) return;
|
||||||
|
buf = buf.subarray(2 + nMethods);
|
||||||
|
client.write(Buffer.from([0x05, 0x00]));
|
||||||
|
phase = "request";
|
||||||
|
}
|
||||||
|
if (phase === "request") {
|
||||||
|
if (buf.length < 5) return;
|
||||||
|
if (buf[0] !== 0x05 || buf[1] !== 0x01) {
|
||||||
|
client.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const atyp = buf[3];
|
||||||
|
let addrEnd: number;
|
||||||
|
let host: string;
|
||||||
|
if (atyp === 0x01) {
|
||||||
|
if (buf.length < 4 + 4 + 2) return;
|
||||||
|
host = `${buf[4]}.${buf[5]}.${buf[6]}.${buf[7]}`;
|
||||||
|
addrEnd = 4 + 4;
|
||||||
|
} else if (atyp === 0x03) {
|
||||||
|
const dlen = buf[4]!;
|
||||||
|
if (buf.length < 4 + 1 + dlen + 2) return;
|
||||||
|
host = buf.subarray(5, 5 + dlen).toString("ascii");
|
||||||
|
addrEnd = 4 + 1 + dlen;
|
||||||
|
} else {
|
||||||
|
client.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const port = (buf[addrEnd]! << 8) | buf[addrEnd + 1]!;
|
||||||
|
destinations.push({ host, port });
|
||||||
|
const totalLen = addrEnd + 2;
|
||||||
|
const remaining = buf.subarray(totalLen);
|
||||||
|
buf = Buffer.alloc(0);
|
||||||
|
|
||||||
|
upstream = net.connect({ host, port }, () => {
|
||||||
|
client.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
|
||||||
|
phase = "tunnel";
|
||||||
|
if (remaining.length > 0) {
|
||||||
|
upstream!.write(remaining);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
upstream.on("data", (data: Buffer) => {
|
||||||
|
client.write(data);
|
||||||
|
});
|
||||||
|
upstream.on("end", () => {
|
||||||
|
try { client.end(); } catch { /* noop */ }
|
||||||
|
});
|
||||||
|
upstream.on("error", () => {
|
||||||
|
try { client.destroy(); } catch { /* noop */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
client.on("data", (chunk: Buffer) => {
|
||||||
|
if (phase === "tunnel") {
|
||||||
|
upstream?.write(chunk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
buf = Buffer.concat([buf, chunk]);
|
||||||
|
tryParse();
|
||||||
|
});
|
||||||
|
client.on("end", () => {
|
||||||
|
try { upstream?.end(); } catch { /* noop */ }
|
||||||
|
});
|
||||||
|
client.on("error", () => {
|
||||||
|
try { upstream?.destroy(); } catch { /* noop */ }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") throw new Error("socks server bind failed");
|
||||||
|
const port = address.port;
|
||||||
|
t.after(async () => {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
port,
|
||||||
|
connectionCount: () => connectionCount,
|
||||||
|
destinations: () => destinations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("createSocksClient routes plain HTTP through the SOCKS5 proxy", async (t) => {
|
||||||
|
const echo = await startEchoServer(t);
|
||||||
|
const socks = await startFakeSocks5(t);
|
||||||
|
|
||||||
|
const client = createSocksClient({ host: "127.0.0.1", port: socks.port });
|
||||||
|
|
||||||
|
const res = await client(`${echo.baseUrl}/cgi-bin/token?appid=AID`);
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const data = await res.json<{ ok: boolean; url: string }>();
|
||||||
|
assert.equal(data.ok, true);
|
||||||
|
assert.equal(data.url, "/cgi-bin/token?appid=AID");
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
socks.connectionCount(),
|
||||||
|
1,
|
||||||
|
"SOCKS5 proxy must have received exactly one connection (proves bytes were routed through it)",
|
||||||
|
);
|
||||||
|
|
||||||
|
const dests = socks.destinations();
|
||||||
|
assert.equal(dests.length, 1);
|
||||||
|
assert.equal(dests[0]!.host, "127.0.0.1");
|
||||||
|
assert.equal(dests[0]!.port, echo.port);
|
||||||
|
|
||||||
|
assert.equal(echo.received.length, 1);
|
||||||
|
assert.equal(echo.received[0]!.method, "GET");
|
||||||
|
assert.equal(echo.received[0]!.url, "/cgi-bin/token?appid=AID");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createSocksClient sends POST body through the SOCKS5 proxy", async (t) => {
|
||||||
|
const echo = await startEchoServer(t);
|
||||||
|
const socks = await startFakeSocks5(t);
|
||||||
|
|
||||||
|
const client = createSocksClient({ host: "127.0.0.1", port: socks.port });
|
||||||
|
|
||||||
|
const body = JSON.stringify({ articles: [{ title: "hi" }] });
|
||||||
|
const res = await client(`${echo.baseUrl}/cgi-bin/draft/add?access_token=T`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(socks.connectionCount(), 1);
|
||||||
|
assert.equal(echo.received.length, 1);
|
||||||
|
assert.equal(echo.received[0]!.method, "POST");
|
||||||
|
assert.equal(echo.received[0]!.headers["content-type"], "application/json");
|
||||||
|
assert.equal(echo.received[0]!.headers["content-length"], String(Buffer.byteLength(body)));
|
||||||
|
assert.equal(echo.received[0]!.body.toString("utf-8"), body);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createSocksClient rejects invalid proxy ports", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => createSocksClient({ host: "127.0.0.1", port: 0 }),
|
||||||
|
/Invalid SOCKS proxy port/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => createSocksClient({ host: "127.0.0.1", port: 70_000 }),
|
||||||
|
/Invalid SOCKS proxy port/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => createSocksClient({ host: "", port: 1080 }),
|
||||||
|
/SOCKS proxy host required/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import net from "node:net";
|
||||||
|
import tls from "node:tls";
|
||||||
|
import { URL } from "node:url";
|
||||||
|
import { SocksClient } from "socks";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
WechatClient,
|
||||||
|
WechatHttpInit,
|
||||||
|
WechatHttpResponse,
|
||||||
|
} from "./wechat-http.ts";
|
||||||
|
|
||||||
|
export interface SocksProxyEndpoint {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSocksClient(proxy: SocksProxyEndpoint): WechatClient {
|
||||||
|
if (!proxy.host) throw new Error("SOCKS proxy host required");
|
||||||
|
if (!Number.isInteger(proxy.port) || proxy.port < 1 || proxy.port > 65535) {
|
||||||
|
throw new Error(`Invalid SOCKS proxy port: ${proxy.port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return async (url, init = {}) => {
|
||||||
|
return wechatHttpViaSocks(url, init, proxy);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wechatHttpViaSocks(
|
||||||
|
rawUrl: string,
|
||||||
|
init: WechatHttpInit,
|
||||||
|
proxy: SocksProxyEndpoint,
|
||||||
|
): Promise<WechatHttpResponse> {
|
||||||
|
const url = new URL(rawUrl);
|
||||||
|
const isHttps = url.protocol === "https:";
|
||||||
|
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
||||||
|
throw new Error(`Unsupported protocol for SOCKS client: ${url.protocol}`);
|
||||||
|
}
|
||||||
|
const targetPort = url.port ? Number(url.port) : isHttps ? 443 : 80;
|
||||||
|
|
||||||
|
const { socket: tcpSocket } = await SocksClient.createConnection({
|
||||||
|
proxy: { host: proxy.host, port: proxy.port, type: 5 },
|
||||||
|
command: "connect",
|
||||||
|
destination: { host: url.hostname, port: targetPort },
|
||||||
|
});
|
||||||
|
|
||||||
|
let stream: net.Socket | tls.TLSSocket;
|
||||||
|
if (isHttps) {
|
||||||
|
const tlsSocket = tls.connect({ socket: tcpSocket, servername: url.hostname });
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const onSecure = () => {
|
||||||
|
tlsSocket.removeListener("error", onError);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const onError = (err: Error) => {
|
||||||
|
tlsSocket.removeListener("secureConnect", onSecure);
|
||||||
|
try {
|
||||||
|
tlsSocket.destroy();
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
tcpSocket.destroy();
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
reject(err);
|
||||||
|
};
|
||||||
|
tlsSocket.once("secureConnect", onSecure);
|
||||||
|
tlsSocket.once("error", onError);
|
||||||
|
});
|
||||||
|
stream = tlsSocket;
|
||||||
|
} else {
|
||||||
|
stream = tcpSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await sendRequestAndReadResponse(stream, url, init);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
stream.destroy();
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendRequestAndReadResponse(
|
||||||
|
stream: net.Socket | tls.TLSSocket,
|
||||||
|
url: URL,
|
||||||
|
init: WechatHttpInit,
|
||||||
|
): Promise<WechatHttpResponse> {
|
||||||
|
const method = init.method ?? (init.body !== undefined ? "POST" : "GET");
|
||||||
|
const body =
|
||||||
|
init.body === undefined
|
||||||
|
? undefined
|
||||||
|
: Buffer.isBuffer(init.body)
|
||||||
|
? init.body
|
||||||
|
: Buffer.from(init.body, "utf-8");
|
||||||
|
|
||||||
|
const userHeaders = init.headers ?? {};
|
||||||
|
const headerMap = new Map<string, string>();
|
||||||
|
for (const [k, v] of Object.entries(userHeaders)) {
|
||||||
|
headerMap.set(k.toLowerCase(), `${k}: ${v}`);
|
||||||
|
}
|
||||||
|
if (!headerMap.has("host")) headerMap.set("host", `Host: ${url.host}`);
|
||||||
|
if (!headerMap.has("user-agent")) {
|
||||||
|
headerMap.set("user-agent", "User-Agent: baoyu-skills-wechat-api");
|
||||||
|
}
|
||||||
|
headerMap.set("connection", "Connection: close");
|
||||||
|
if (body && !headerMap.has("content-length")) {
|
||||||
|
headerMap.set("content-length", `Content-Length: ${body.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = `${url.pathname || "/"}${url.search}`;
|
||||||
|
const requestHeader = Buffer.from(
|
||||||
|
`${method} ${path} HTTP/1.1\r\n` +
|
||||||
|
Array.from(headerMap.values()).join("\r\n") +
|
||||||
|
"\r\n\r\n",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
await writeAll(stream, requestHeader);
|
||||||
|
if (body) await writeAll(stream, body);
|
||||||
|
|
||||||
|
const raw = await readToEnd(stream);
|
||||||
|
return parseHttpResponse(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAll(stream: net.Socket | tls.TLSSocket, data: Buffer): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
stream.write(data, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readToEnd(stream: net.Socket | tls.TLSSocket): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
stream.once("end", () => resolve(Buffer.concat(chunks)));
|
||||||
|
stream.once("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHttpResponse(raw: Buffer): WechatHttpResponse {
|
||||||
|
const headerEnd = raw.indexOf("\r\n\r\n");
|
||||||
|
if (headerEnd < 0) {
|
||||||
|
throw new Error("Malformed HTTP response: missing header terminator");
|
||||||
|
}
|
||||||
|
const headerText = raw.subarray(0, headerEnd).toString("utf-8");
|
||||||
|
let bodyBytes = raw.subarray(headerEnd + 4);
|
||||||
|
|
||||||
|
const lines = headerText.split("\r\n");
|
||||||
|
const statusLine = lines.shift() ?? "";
|
||||||
|
const statusMatch = statusLine.match(/^HTTP\/[\d.]+\s+(\d+)(?:\s+(.*))?$/);
|
||||||
|
if (!statusMatch) {
|
||||||
|
throw new Error(`Malformed HTTP status line: ${statusLine}`);
|
||||||
|
}
|
||||||
|
const status = Number.parseInt(statusMatch[1]!, 10);
|
||||||
|
const statusText = statusMatch[2] ?? "";
|
||||||
|
|
||||||
|
const headers: Record<string, string | string[] | undefined> = {};
|
||||||
|
const lowercaseHeaders: Record<string, string> = {};
|
||||||
|
for (const line of lines) {
|
||||||
|
const colon = line.indexOf(":");
|
||||||
|
if (colon < 0) continue;
|
||||||
|
const key = line.slice(0, colon).trim();
|
||||||
|
const value = line.slice(colon + 1).trim();
|
||||||
|
const lower = key.toLowerCase();
|
||||||
|
const existing = headers[lower];
|
||||||
|
if (existing === undefined) {
|
||||||
|
headers[lower] = value;
|
||||||
|
} else if (Array.isArray(existing)) {
|
||||||
|
existing.push(value);
|
||||||
|
} else {
|
||||||
|
headers[lower] = [existing, value];
|
||||||
|
}
|
||||||
|
lowercaseHeaders[lower] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transferEncoding = (lowercaseHeaders["transfer-encoding"] ?? "").toLowerCase();
|
||||||
|
if (transferEncoding.split(",").map((s) => s.trim()).includes("chunked")) {
|
||||||
|
bodyBytes = dechunk(bodyBytes);
|
||||||
|
} else if (lowercaseHeaders["content-length"] !== undefined) {
|
||||||
|
const length = Number.parseInt(lowercaseHeaders["content-length"]!, 10);
|
||||||
|
if (Number.isFinite(length) && length >= 0) {
|
||||||
|
bodyBytes = bodyBytes.subarray(0, length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
statusText,
|
||||||
|
headers,
|
||||||
|
async buffer() {
|
||||||
|
return bodyBytes;
|
||||||
|
},
|
||||||
|
async text() {
|
||||||
|
return bodyBytes.toString("utf-8");
|
||||||
|
},
|
||||||
|
async json<T = unknown>() {
|
||||||
|
return JSON.parse(bodyBytes.toString("utf-8")) as T;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dechunk(raw: Buffer): Buffer {
|
||||||
|
const parts: Buffer[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < raw.length) {
|
||||||
|
const lineEnd = raw.indexOf("\r\n", offset);
|
||||||
|
if (lineEnd < 0) break;
|
||||||
|
const sizeText = raw.subarray(offset, lineEnd).toString("ascii").split(";")[0]!.trim();
|
||||||
|
const size = Number.parseInt(sizeText, 16);
|
||||||
|
if (!Number.isFinite(size) || size < 0) {
|
||||||
|
throw new Error(`Invalid chunked-encoding size: ${sizeText}`);
|
||||||
|
}
|
||||||
|
offset = lineEnd + 2;
|
||||||
|
if (size === 0) break;
|
||||||
|
if (offset + size > raw.length) {
|
||||||
|
throw new Error("Chunked-encoding body truncated");
|
||||||
|
}
|
||||||
|
parts.push(raw.subarray(offset, offset + size));
|
||||||
|
offset += size + 2;
|
||||||
|
}
|
||||||
|
return Buffer.concat(parts);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: baoyu-slide-deck
|
name: baoyu-slide-deck
|
||||||
description: Generates professional slide deck images from content. Creates outlines with style instructions, then generates individual slide images. Use when user asks to "create slides", "make a presentation", "generate deck", "slide deck", or "PPT".
|
description: Generates professional slide deck images from content. Creates outlines with style instructions, then generates individual slide images. Use when user asks to "create slides", "make a presentation", "generate deck", "slide deck", or "PPT".
|
||||||
version: 1.56.2
|
version: 1.57.0
|
||||||
metadata:
|
metadata:
|
||||||
openclaw:
|
openclaw:
|
||||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-slide-deck
|
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-slide-deck
|
||||||
@@ -40,12 +40,31 @@ When this skill needs to render an image, resolve the backend in this order:
|
|||||||
|
|
||||||
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
|
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
|
||||||
|
|
||||||
|
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace slide titles, bullets, or any other text inside an already generated slide image. If text is wrong or unclear, regenerate from a corrected prompt, simplify the slide's on-image text, or ask the user which imperfect candidate to keep.
|
||||||
|
|
||||||
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
|
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
|
||||||
|
|
||||||
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-slide-[slug].md`) BEFORE invoking any backend. The file is the reproducibility record and lets you switch backends without regenerating prompts.
|
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-slide-[slug].md`) BEFORE invoking any backend. The file is the reproducibility record and lets you switch backends without regenerating prompts.
|
||||||
|
|
||||||
Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) above are examples — substitute the local equivalents under the same rule.
|
Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) above are examples — substitute the local equivalents under the same rule.
|
||||||
|
|
||||||
|
## Batch Generation Policy
|
||||||
|
|
||||||
|
After every prompt file for the current generation group has been saved and verified, generate slide images in batches by default.
|
||||||
|
|
||||||
|
Priority order:
|
||||||
|
|
||||||
|
1. Use the chosen backend's native batch / multi-task interface if it exists. Each task must keep its own prompt file, output path, aspect ratio, session ID, and direct reference images.
|
||||||
|
2. If no native batch interface exists but the runtime can issue parallel tool calls, dispatch up to `generation_batch_size` slide images at a time. Default: `4`. An explicit user request in the current message, such as `--batch-size 4` or "并行4张一起生成", overrides EXTEND.md.
|
||||||
|
3. If neither native batch nor parallel tool calls are available, generate sequentially.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Never start the first batch until all selected slide prompt files exist on disk.
|
||||||
|
- Retry failed items once without regenerating successful items.
|
||||||
|
- Do not use subagents merely to parallelize image rendering. Use subagents only for separate prompt iteration or creative exploration.
|
||||||
|
- Merge PPTX/PDF only after all selected slide images are generated.
|
||||||
|
|
||||||
## Confirmation Policy
|
## Confirmation Policy
|
||||||
|
|
||||||
Default behavior: **confirm before generation**.
|
Default behavior: **confirm before generation**.
|
||||||
@@ -77,6 +96,7 @@ Respond in the user's language across questions, progress reports, error message
|
|||||||
| `--lang <code>` | Output language (en, zh, ja, ...) |
|
| `--lang <code>` | Output language (en, zh, ja, ...) |
|
||||||
| `--slides <N>` | Target slide count (8-25 recommended, max 30) |
|
| `--slides <N>` | Target slide count (8-25 recommended, max 30) |
|
||||||
| `--ref <files...>` | Reference images applied per slide (style / palette / composition / subject) |
|
| `--ref <files...>` | Reference images applied per slide (style / palette / composition / subject) |
|
||||||
|
| `--batch-size <n>` | Temporary slide image generation batch size for this run. Default: `generation_batch_size` from EXTEND.md, otherwise 4. Clamp to 1-8. |
|
||||||
| `--outline-only` | Stop after outline |
|
| `--outline-only` | Stop after outline |
|
||||||
| `--prompts-only` | Stop after prompts (skip image generation) |
|
| `--prompts-only` | Stop after prompts (skip image generation) |
|
||||||
| `--images-only` | Skip to Step 7; requires existing `prompts/` |
|
| `--images-only` | Skip to Step 7; requires existing `prompts/` |
|
||||||
@@ -223,7 +243,7 @@ Copy this checklist and check off items as you complete them:
|
|||||||
| `${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-slide-deck/EXTEND.md` | XDG |
|
| `${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-slide-deck/EXTEND.md` | XDG |
|
||||||
| `$HOME/.baoyu-skills/baoyu-slide-deck/EXTEND.md` | User home |
|
| `$HOME/.baoyu-skills/baoyu-slide-deck/EXTEND.md` | User home |
|
||||||
|
|
||||||
If found, read, parse, and print a summary (style / audience / language / review). If not, proceed with defaults — first-time setup is not blocking for this skill. Schema: `references/config/preferences-schema.md`.
|
If found, read, parse, and print a summary (style / audience / language / review / generation batch size). If not, proceed with defaults — first-time setup is not blocking for this skill. Schema: `references/config/preferences-schema.md`.
|
||||||
|
|
||||||
**1.2 Analyze content** — follow `references/analysis-framework.md`: classify content, detect language, note signals for style selection, estimate slide count from length (see the **Slide Count Heuristic** in Style System above), generate topic slug. Save source as `source.md` (honor backup rule if one exists).
|
**1.2 Analyze content** — follow `references/analysis-framework.md`: classify content, detect language, note signals for style selection, estimate slide count from length (see the **Slide Count Heuristic** in Style System above), generate topic slug. Save source as `source.md` (honor backup rule if one exists).
|
||||||
|
|
||||||
@@ -279,7 +299,8 @@ Display the prompts index (`# | Filename | Slide Title`) and ask: proceed / edit
|
|||||||
1. Resolve the image backend via the Image Generation Tools rule at the top — ask once if multiple are installed.
|
1. Resolve the image backend via the Image Generation Tools rule at the top — ask once if multiple are installed.
|
||||||
2. Confirm every `prompts/NN-slide-{slug}.md` exists (hard requirement; prompt files are the reproducibility record regardless of backend).
|
2. Confirm every `prompts/NN-slide-{slug}.md` exists (hard requirement; prompt files are the reproducibility record regardless of backend).
|
||||||
3. Session ID: `slides-{topic-slug}-{timestamp}` — pass to the backend only if it supports sessions.
|
3. Session ID: `slides-{topic-slug}-{timestamp}` — pass to the backend only if it supports sessions.
|
||||||
4. For each slide: generate sequentially, reusing the session ID. Backup rule applies to PNG files. Report progress as `Generated X/N`. Auto-retry once on failure before reporting an error.
|
4. Build a task list for selected slides with each slide's prompt file, output PNG path, aspect ratio, session ID, and verified direct references.
|
||||||
|
5. Dispatch slide images in batches per the `## Batch Generation Policy`: backend native batch first, runtime parallel tool calls second, sequential only as fallback. Backup rule applies to PNG files before dispatch. Report progress as `Generated X/N`. Retry only failed items once before reporting an error.
|
||||||
|
|
||||||
`--regenerate N` jumps to this step for the named slides only. `--images-only` starts here with existing prompts.
|
`--regenerate N` jumps to this step for the named slides only. `--images-only` starts here with existing prompts.
|
||||||
|
|
||||||
@@ -318,6 +339,12 @@ PDF: {topic-slug}.pdf
|
|||||||
|
|
||||||
Always update the prompt file before regenerating the image — this keeps the prompts directory as the source of truth and makes changes reproducible. Only `NN` changes on renumber; slugs stay stable so references remain valid.
|
Always update the prompt file before regenerating the image — this keeps the prompts directory as the source of truth and makes changes reproducible. Only `NN` changes on renumber; slugs stay stable so references remain valid.
|
||||||
|
|
||||||
|
Text correction policy:
|
||||||
|
|
||||||
|
- If a slide's title, bullets, or any other rendered text is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
|
||||||
|
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
|
||||||
|
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
|
||||||
|
|
||||||
See `references/modification-guide.md` for full details.
|
See `references/modification-guide.md` for full details.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
@@ -352,4 +379,5 @@ EXTEND.md lives at the first matching path listed in Step 1.1. Two ways to chang
|
|||||||
- `preferred_image_backend: codex-imagegen` — pin to Codex's built-in.
|
- `preferred_image_backend: codex-imagegen` — pin to Codex's built-in.
|
||||||
- `preferred_image_backend: baoyu-imagine` — pin to the baoyu-imagine skill.
|
- `preferred_image_backend: baoyu-imagine` — pin to the baoyu-imagine skill.
|
||||||
- `preferred_image_backend: ask` — confirm backend every run.
|
- `preferred_image_backend: ask` — confirm backend every run.
|
||||||
|
- `generation_batch_size: 4` — default number of slide images to render concurrently when the backend/runtime supports batch or parallel generation.
|
||||||
- `preferred_style: blueprint`, `preferred_audience: experts`, `language: zh`.
|
- `preferred_style: blueprint`, `preferred_audience: experts`, `language: zh`.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ audience: general # beginners | intermediate | experts | executives
|
|||||||
language: auto # auto | en | zh | ja | etc.
|
language: auto # auto | en | zh | ja | etc.
|
||||||
review: true # true = review outline before generation
|
review: true # true = review outline before generation
|
||||||
preferred_image_backend: auto # auto | ask | <backend-id>
|
preferred_image_backend: auto # auto | ask | <backend-id>
|
||||||
|
generation_batch_size: 4 # 1-8, used when backend/runtime supports batch or parallel slide generation
|
||||||
|
|
||||||
## Custom Dimensions (only when style: custom)
|
## Custom Dimensions (only when style: custom)
|
||||||
dimensions:
|
dimensions:
|
||||||
@@ -42,6 +43,7 @@ custom_styles:
|
|||||||
| `language` | string | `auto` | Output language (auto = detect from input) |
|
| `language` | string | `auto` | Output language (auto = detect from input) |
|
||||||
| `review` | boolean | `true` | Show outline review before generation |
|
| `review` | boolean | `true` | Show outline review before generation |
|
||||||
| `preferred_image_backend` | string | `auto` | Image backend selection. `auto` = prefer runtime-native tool, fall back to the only installed backend, ask if multiple non-native are present. `ask` = always confirm on every run. `<backend-id>` (e.g., `codex-imagegen`, `baoyu-imagine`, `image_generate`) = pin this backend when available; fall back to `auto` when it isn't. Absent = `auto`. Resolution logic is documented in `SKILL.md`'s `## Image Generation Tools` section. |
|
| `preferred_image_backend` | string | `auto` | Image backend selection. `auto` = prefer runtime-native tool, fall back to the only installed backend, ask if multiple non-native are present. `ask` = always confirm on every run. `<backend-id>` (e.g., `codex-imagegen`, `baoyu-imagine`, `image_generate`) = pin this backend when available; fall back to `auto` when it isn't. Absent = `auto`. Resolution logic is documented in `SKILL.md`'s `## Image Generation Tools` section. |
|
||||||
|
| `generation_batch_size` | int | 4 | Number of slide images to dispatch per batch when the backend has native batch support or the runtime can issue parallel generation calls. Clamp invalid values to 1-8. Current user request overrides this value. |
|
||||||
|
|
||||||
### Custom Dimensions
|
### Custom Dimensions
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: baoyu-wechat-summary
|
name: baoyu-wechat-summary
|
||||||
description: Summarizes WeChat group chat highlights into a structured digest using the local wx-cli binary (https://github.com/jackwener/wx-cli). Generates a normal digest by default; a roast (毒舌) version is opt-in. Maintains per-group history (history.json + history-digests.jsonl) and per-user profiles across runs, with privacy guardrails baked in. Use when the user asks to "总结群聊", "群聊精华", "群聊摘要", "summarize group chat", "group chat digest", mentions a WeChat group name with a time range, says "帮我看看 XX 群最近聊了什么", "XX 群有什么值得看的", or asks to "回溯画像" / "初始化画像" / "backfill profiles". Adds the roast version when the user says "毒舌版", "roast 版", "再来个毒舌的", or similar.
|
description: Summarizes WeChat group chat highlights into a structured digest using the local wx-cli binary (https://github.com/jackwener/wx-cli). Generates a normal digest by default; a roast (毒舌) version is opt-in. Maintains per-group history (history.json + history-digests.jsonl) and per-user profiles across runs, with privacy guardrails baked in. Use when the user asks to "总结群聊", "群聊精华", "群聊摘要", "summarize group chat", "group chat digest", mentions a WeChat group name with a time range, says "帮我看看 XX 群最近聊了什么", "XX 群有什么值得看的", or asks to "回溯画像" / "初始化画像" / "backfill profiles". Adds the roast version when the user says "毒舌版", "roast 版", "再来个毒舌的", or similar.
|
||||||
version: 0.1.0
|
version: 1.117.3
|
||||||
metadata:
|
metadata:
|
||||||
openclaw:
|
openclaw:
|
||||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-wechat-summary
|
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-wechat-summary
|
||||||
@@ -97,9 +97,10 @@ For option 2, scan the sessions for any private/group thread the user has sent i
|
|||||||
- `self_wxid` (e.g., `wxid_abc123`) — fall-back hint: the user can find it with `wx contacts --query "<own nickname>"`, or by inspecting any of their own sent messages in `wx sessions --json`
|
- `self_wxid` (e.g., `wxid_abc123`) — fall-back hint: the user can find it with `wx contacts --query "<own nickname>"`, or by inspecting any of their own sent messages in `wx sessions --json`
|
||||||
- `self_display` (e.g., `宝玉`) — how they want their messages attributed
|
- `self_display` (e.g., `宝玉`) — how they want their messages attributed
|
||||||
- `default_version` — pick one of `normal` / `roast` / `both`
|
- `default_version` — pick one of `normal` / `roast` / `both`
|
||||||
|
- `data_root` — where digest folders live. Default: `{project_root}/wechat`. Enter a custom absolute path (e.g. `~/Documents/wechat-digests`) or leave blank for default.
|
||||||
- Save location — pick one of project / XDG / home
|
- Save location — pick one of project / XDG / home
|
||||||
|
|
||||||
Write EXTEND.md to the chosen path, confirm "Preferences saved to [path]. Edit it any time to change defaults.", then continue with the digest workflow.
|
Write EXTEND.md to the chosen path. If the user provided a non-default `data_root`, include it as an uncommented line; otherwise omit it (the default applies automatically). Confirm "Preferences saved to [path]. Edit it any time to change defaults.", then continue with the digest workflow.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
@@ -285,6 +286,13 @@ The `imgs/` directory exists as an **extension point**: a user (or a future wx-c
|
|||||||
- Callback past quotes ("继上次'要不要买 moderna'之后,这次又...")
|
- Callback past quotes ("继上次'要不要买 moderna'之后,这次又...")
|
||||||
- Don't sacrifice current material to force a callback.
|
- Don't sacrifice current material to force a callback.
|
||||||
|
|
||||||
|
**Roast pass — profile usage extras** (only when generating the roast version):
|
||||||
|
|
||||||
|
- 历史槽点可做 callback joke
|
||||||
|
- Running gag 可以升级和迭代
|
||||||
|
- 历史毒舌语录可以引用或翻新
|
||||||
|
- 但当期素材优先,不要为了 callback 硬凑
|
||||||
|
|
||||||
**Writing order**: write the body categories first, then the opening overview based on the finished body (so the hook is accurate).
|
**Writing order**: write the body categories first, then the opening overview based on the finished body (so the hook is accurate).
|
||||||
|
|
||||||
Detailed structure, voice, formatting rules, and content guidelines are in [references/output-formats.md](references/output-formats.md). Load that file now if not already loaded.
|
Detailed structure, voice, formatting rules, and content guidelines are in [references/output-formats.md](references/output-formats.md). Load that file now if not already loaded.
|
||||||
@@ -324,8 +332,8 @@ Always reflects only the most recent normal digest. Overwrite on each run when `
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"group_id": "12345678901@chatroom",
|
"group_id": "12345678901@chatroom",
|
||||||
"group_name": "消失的大叔",
|
"group_name": "相亲相爱一家人",
|
||||||
"folder": "12345678901@chatroom-消失的大叔",
|
"folder": "12345678901@chatroom-相亲相爱一家人",
|
||||||
"last_digest": {
|
"last_digest": {
|
||||||
"file": "2026-03-12.md",
|
"file": "2026-03-12.md",
|
||||||
"date_range": "2026-03-12",
|
"date_range": "2026-03-12",
|
||||||
@@ -392,7 +400,7 @@ Full procedure in [references/profiles.md](references/profiles.md).
|
|||||||
|
|
||||||
```
|
```
|
||||||
{data_root}/ # default: {project_root}/wechat/
|
{data_root}/ # default: {project_root}/wechat/
|
||||||
└── {group_id}-{group_name}/ # e.g. 12345678901@chatroom-消失的大叔/
|
└── {group_id}-{group_name}/ # e.g. 12345678901@chatroom-相亲相爱一家人/
|
||||||
├── history.json # last digest pointer (fast)
|
├── history.json # last digest pointer (fast)
|
||||||
├── history-digests.jsonl # append-only archive
|
├── history-digests.jsonl # append-only archive
|
||||||
├── 2026-03-12.md # normal digest, single date
|
├── 2026-03-12.md # normal digest, single date
|
||||||
|
|||||||
@@ -130,103 +130,27 @@ No date, no signature, no version number.
|
|||||||
|
|
||||||
## 2. Roast version (毒舌版)
|
## 2. Roast version (毒舌版)
|
||||||
|
|
||||||
The roast version mirrors the normal version's structure but inverts the tone. Generate only when `include_roast=true` (configured via EXTEND.md `default_version` or triggered by request keywords like 毒舌/roast/挑衅).
|
Roast 版基于普通版的话题骨架和素材,用毒舌、尖锐、挑衅的风格重写。整体结构与普通版相同(统计区块、开头概览、群友画像、正文分类、结尾),但风格完全不同。痛点部分省略。仅当 `include_roast=true` 时生成。标题加 "毒舌版" 后缀。
|
||||||
|
|
||||||
### 2.1 Structural parity
|
风格要求:
|
||||||
|
- 你是一位以尖锐和挑衅风格著称的专业评论员
|
||||||
|
- 对每个群友的行为、言论进行犀利点评,不怕让人尴尬
|
||||||
|
- 发言排行旁给每个人加一句毒舌备注(括号内)
|
||||||
|
- 群友画像改为「不留情面版」,放大每个人的槽点和矛盾之处
|
||||||
|
- 开头概览用更戏谑的口吻,突出荒诞和讽刺
|
||||||
|
- 正文话题标题可以改得更损
|
||||||
|
- 引用原话时配上辛辣点评
|
||||||
|
- 结尾改为:本简报由一个没有感情的 AI 自动生成,如有冒犯,概不负责
|
||||||
|
|
||||||
```
|
注意:毒舌但不恶毒,调侃但不人身攻击。目标是让群友看了会笑,而不是生气。具体红线:
|
||||||
[Title line — adds "毒舌版" suffix]
|
- 只嘲讽群里的公开行为,不碰外貌、体重、健康、家庭、私人关系
|
||||||
[📊 Stats block — each leaderboard row gets a roast comment]
|
- 不用时间戳推断作息或时区(服务器时间不等于本地时间)
|
||||||
[Opening summary — absurd recap, sarcastic]
|
- 不做医学/心理诊断类玩笑(「这位需要看医生」「典型 ADHD」)
|
||||||
[群友画像 — 不留情面版]
|
- 不揣测对方未主动公开的身份属性(性取向、宗教、政治立场)
|
||||||
[Categorized body — louder, more brutal category titles]
|
- 嘲讽观点本身,不嘲讽发言的权利(「这个观点错得离谱」可以,「连这都不懂还敢发言」不行)
|
||||||
[Fixed footer — roast version]
|
- 如果某人本期没有槽点(3+ 条但都很中性),给一句温和调侃即可,不要硬凑
|
||||||
```
|
|
||||||
|
|
||||||
Pain-point section is **dropped** in the roast version.
|
**写作顺序:** 先放开写最狠的版本,写完再回头检查红线。不要边写边自我审查,那样只会写出温吞水。
|
||||||
|
|
||||||
### 2.2 Title line
|
|
||||||
|
|
||||||
Form: `{群名} 群聊精华 · {日期} · 毒舌版`
|
|
||||||
|
|
||||||
Example: `相亲相爱一家人 群聊精华 · 2026-03-12 · 毒舌版`
|
|
||||||
|
|
||||||
### 2.3 Statistics block (roast)
|
|
||||||
|
|
||||||
- Same `📊 消息统计: 共 N 条消息` opener.
|
|
||||||
- Leaderboard: each row gets a parenthetical roast comment.
|
|
||||||
- Form: `{排名}. {昵称}: {消息数} 条 ({一句毒舌评语})`
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```
|
|
||||||
📊 消息统计: 共 387 条消息
|
|
||||||
1. 蛙总: 92 条 (一个人撑起了空头的体面)
|
|
||||||
2. 老王: 58 条 (主要功能是给蛙总当反例)
|
|
||||||
3. 阿喵: 41 条 (发的链接比发的话还多)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.4 Opening summary (roast)
|
|
||||||
|
|
||||||
- 1-2 paragraphs, sarcastic recap tone.
|
|
||||||
- Highlight the day's most ridiculous beat (a failed prediction, a heated argument over something trivial, a wild flex).
|
|
||||||
- Reference 2-3 of the roast category titles in the prose.
|
|
||||||
|
|
||||||
### 2.5 群友画像 (roast — 不留情面版)
|
|
||||||
|
|
||||||
- Same per-user entry format as normal, but with the role tag dialed up.
|
|
||||||
- Amplify quirks, contradictions, fail moments visible in the messages.
|
|
||||||
- 2-5 bullets with `•`. Each bullet is a roastable observation backed by a direct quote.
|
|
||||||
- Don't invent — every roast must trace back to something the person actually said in the batch (or in the loaded roast profile from prior batches).
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```
|
|
||||||
群友画像
|
|
||||||
|
|
||||||
蛙总(做空美股钉子户,三周亏损不改其志)
|
|
||||||
• 今天第 47 次预测 SPY 见顶,给出的理由是"我感觉"
|
|
||||||
• 被老王翻出上周聊天记录后嘴硬:「上周的不算,那是市场不理性」
|
|
||||||
• 收盘前突然安静,疑似刚看完账户
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.6 Categorized body (roast)
|
|
||||||
|
|
||||||
- Same 3-6 category structure.
|
|
||||||
- Titles can be louder, more brutal, mock-headline style.
|
|
||||||
- Examples of acceptable roast titles:
|
|
||||||
- `蛙总做空翻车: 一个人对抗整个美股`
|
|
||||||
- `老王再次试图当人生导师, 群友集体打哈欠`
|
|
||||||
- `阿喵又分享了一篇没人读完的长文`
|
|
||||||
- Bodies still preserve real quotes and traceability — sarcasm is in framing, not fabrication.
|
|
||||||
|
|
||||||
### 2.7 Footer (roast)
|
|
||||||
|
|
||||||
Fixed line, last in file:
|
|
||||||
|
|
||||||
```
|
|
||||||
本简报由一个没有感情的 AI 自动生成,如有冒犯,概不负责
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.8 Red lines (non-negotiable)
|
|
||||||
|
|
||||||
These rules override style; violate them and the roast becomes harmful. Write the spicy version first, then audit against this list and rewrite anything that crosses a line.
|
|
||||||
|
|
||||||
- **Mock public group behavior only.** Never appearance, weight, body, health, mental state, family, relationships (unless openly group-discussed at the person's own initiative), finances beyond what they publicly mention.
|
|
||||||
- **调侃 ≠ 人身攻击。** Readers should laugh at the situation, not feel sorry for the target. If you can't think of a joke that doesn't read as cruel, drop the bullet.
|
|
||||||
- **No timestamp-based sleep/timezone jokes.** Server time ≠ recipient's local time; also implies surveillance. Forbidden: `凌晨 3 点还在群里发言,怕是没睡好`, `这位是哪个时区的`, etc.
|
|
||||||
- **No medical/psychological speculation.** Even joking diagnoses (`这位需要看医生`, `典型 ADHD`) are out.
|
|
||||||
- **No outing.** Don't infer identity attributes the person hasn't volunteered (orientation, religion, politics beyond direct quotes).
|
|
||||||
- **Roast the take, not the person's right to have a take.** `这个观点错得离谱` is fine. `连这都不懂还敢发言` is not.
|
|
||||||
|
|
||||||
If a target hasn't said anything roastable in this batch (3+ messages but all neutral), give them a one-line affectionate ribbing and move on. Don't manufacture conflict.
|
|
||||||
|
|
||||||
### 2.9 Writing order
|
|
||||||
|
|
||||||
1. Draft the spicy version freely — don't self-censor mid-sentence.
|
|
||||||
2. After the body is complete, do a separate audit pass against §2.8.
|
|
||||||
3. Rewrite or delete any line that crosses a red line.
|
|
||||||
4. Then read the whole thing once more for tone calibration: is it fun, or is it mean?
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -345,5 +269,5 @@ Before writing the digest file, mentally walk through:
|
|||||||
6. Every quote uses 「」 and is traceable to a real message?
|
6. Every quote uses 「」 and is traceable to a real message?
|
||||||
7. Links inline and complete?
|
7. Links inline and complete?
|
||||||
8. No markdown bold/heading/link syntax leaked through?
|
8. No markdown bold/heading/link syntax leaked through?
|
||||||
9. (Roast only) Every roast bullet would pass the §2.8 red-line audit?
|
9. (Roast only) Every roast bullet would pass the §2 红线 audit?
|
||||||
10. Footer line exact match?
|
10. Footer line exact match?
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ YAML frontmatter at the top of every profile file:
|
|||||||
---
|
---
|
||||||
name: "<current display name>"
|
name: "<current display name>"
|
||||||
wxid: "<wxid>"
|
wxid: "<wxid>"
|
||||||
aliases: ["<old nickname>", "<even older nickname>"]
|
group_nicknames: ["<历史群昵称 1>", "<历史群昵称 2>"]
|
||||||
|
aliases: ["<群友给的称呼 1>", "<群友给的称呼 2>"]
|
||||||
|
tags: ["<标签 1>", "<标签 2>"]
|
||||||
first_seen: "YYYY-MM-DD"
|
first_seen: "YYYY-MM-DD"
|
||||||
last_seen: "YYYY-MM-DD"
|
last_seen: "YYYY-MM-DD"
|
||||||
total_messages: N
|
total_messages: N
|
||||||
@@ -45,12 +47,16 @@ Field rules:
|
|||||||
|
|
||||||
- `name`: the most recent display name from `from_nickname` (or `self_display` for the owning user).
|
- `name`: the most recent display name from `from_nickname` (or `self_display` for the owning user).
|
||||||
- `wxid`: stable; never changes once written.
|
- `wxid`: stable; never changes once written.
|
||||||
- `aliases`: append-only; every prior display name we've seen. Don't include the current `name` in this list.
|
- `group_nicknames`: append-only history of the user's own prior display names in the group. Push the prior `name` here when `name` changes. Dedupe, preserve chronological order (oldest → newest). Do not include the current `name`.
|
||||||
|
- `aliases`: nicknames **other members** call this user (e.g., `蛙总`, `老王`, `X 哥`). Dedupe-append when observed in this batch. Do not include the current `name`, and do not duplicate `group_nicknames` entries — those record the user's own past handles, not how the group addresses them.
|
||||||
|
- `tags`: free-form labels for the user, **independent** of the body's 角色标签 / 人设标签 section. Use for cross-cutting attributes that don't fit the role/personality framing (region, profession, community, recurring long-form interests, etc.). Agent may append or refine when observing stable patterns. No hard cap.
|
||||||
- `first_seen` / `last_seen`: dates of first/most-recent digest appearance, YYYY-MM-DD.
|
- `first_seen` / `last_seen`: dates of first/most-recent digest appearance, YYYY-MM-DD.
|
||||||
- `total_messages`: cumulative count across all digests this profile has been updated from.
|
- `total_messages`: cumulative count across all digests this profile has been updated from.
|
||||||
- `digest_appearances`: how many digest files this user has 3+ messages in.
|
- `digest_appearances`: how many digest files this user has 3+ messages in.
|
||||||
- `avg_messages_per_digest`: `total_messages / digest_appearances`, one decimal.
|
- `avg_messages_per_digest`: `total_messages / digest_appearances`, one decimal.
|
||||||
|
|
||||||
|
**Backwards compatibility**: earlier versions of this skill used `aliases` for what is now `group_nicknames`. When reading an existing profile that lacks `group_nicknames` or `tags`, treat missing fields as `[]` and add them on the next write. **Do not auto-migrate** non-empty legacy `aliases` values — the agent can't reliably tell historical display names apart from community-given nicknames. Leave the values in `aliases`; the user can move historical display names into `group_nicknames` manually if desired.
|
||||||
|
|
||||||
### 1.3 Free-form body — normal profile
|
### 1.3 Free-form body — normal profile
|
||||||
|
|
||||||
Section headers are plain text on their own line. Order is fixed.
|
Section headers are plain text on their own line. Order is fixed.
|
||||||
@@ -138,8 +144,16 @@ Rules differ per section. Append-only sections must never lose history; mergeabl
|
|||||||
|
|
||||||
### 2.3 Frontmatter on every update
|
### 2.3 Frontmatter on every update
|
||||||
|
|
||||||
- Update `name` if current display name differs from the recorded one. Push the old name onto `aliases` if not already there.
|
- If the current display name differs from the recorded `name`:
|
||||||
- If `name` changed, also rename the file from `{wxid}-{old_nickname}.md` to `{wxid}-{new_nickname}.md`.
|
- Push the old `name` onto `group_nicknames` if not already there (dedupe, preserve chronological order).
|
||||||
|
- Update `name` to the current display name.
|
||||||
|
- Rename the file from `{wxid}-{old_nickname}.md` to `{wxid}-{new_nickname}.md`.
|
||||||
|
- Scan this batch for nicknames **other members** use to address this user, and dedupe-append into `aliases`. Signals:
|
||||||
|
- `@mention` resolving to this `wxid`.
|
||||||
|
- Direct salutations targeting this user with a name different from `name` (e.g., `蛙总你怎么看`, `老王说得对`).
|
||||||
|
- Quoted references in the digest body that name this user as someone other than their current `name`.
|
||||||
|
- Only add when attribution is unambiguous; skip uncertain matches.
|
||||||
|
- If this batch reveals a stable cross-cutting attribute that doesn't fit the role/personality framing of 角色标签 / 人设标签 (region, profession, community, durable interest, etc.), append or refine `tags`. `tags` is independent of the body's tag sections — don't mirror them.
|
||||||
- Update `last_seen` to the current digest's end date.
|
- Update `last_seen` to the current digest's end date.
|
||||||
- Increment `total_messages` by this batch's message count for this user.
|
- Increment `total_messages` by this batch's message count for this user.
|
||||||
- Increment `digest_appearances` by 1.
|
- Increment `digest_appearances` by 1.
|
||||||
@@ -154,7 +168,7 @@ Run after the digest file(s) are written. Iterate over every user with 3+ messag
|
|||||||
1. **Look up the profile.**
|
1. **Look up the profile.**
|
||||||
- Scan `profiles/` (or `profiles-roast/` for the roast pass) for a file whose name starts with `{wxid}-`.
|
- Scan `profiles/` (or `profiles-roast/` for the roast pass) for a file whose name starts with `{wxid}-`.
|
||||||
- If found: open it.
|
- If found: open it.
|
||||||
- If not found: create a new file using the frontmatter template. `first_seen = last_seen = current digest end date`, `total_messages = this batch's count`, `digest_appearances = 1`.
|
- If not found: create a new file using the frontmatter template. `group_nicknames = []`, `aliases = []`, `tags = []`, `first_seen = last_seen = current digest end date`, `total_messages = this batch's count`, `digest_appearances = 1`. Then run §2.3 to seed observed aliases/tags from this batch.
|
||||||
|
|
||||||
2. **Resolve wxid for new users.** When a new user appears, you already know their `wxid` from the wx-cli message data — use it directly. If for some reason only the nickname is known, run `wx contacts --query "{nickname}" --json` to resolve; if multiple matches, prefer the one currently in the group (cross-check `wx members <group>` if needed).
|
2. **Resolve wxid for new users.** When a new user appears, you already know their `wxid` from the wx-cli message data — use it directly. If for some reason only the nickname is known, run `wx contacts --query "{nickname}" --json` to resolve; if multiple matches, prefer the one currently in the group (cross-check `wx members <group>` if needed).
|
||||||
|
|
||||||
@@ -195,7 +209,7 @@ Triggered when the user says `回溯画像`, `初始化画像`, `backfill profil
|
|||||||
5. **Write profile files.**
|
5. **Write profile files.**
|
||||||
- For the normal pass, write to `profiles/{wxid}-{nickname}.md`.
|
- For the normal pass, write to `profiles/{wxid}-{nickname}.md`.
|
||||||
- For the roast pass, write to `profiles-roast/{wxid}-{nickname}.md`.
|
- For the roast pass, write to `profiles-roast/{wxid}-{nickname}.md`.
|
||||||
- Use the most recent nickname as the filename suffix. Push older nicknames into `aliases`.
|
- Use the most recent nickname as the filename suffix. Push older display names into `group_nicknames` (see step 6 for the field-by-field rules).
|
||||||
- Sort 经典金句,标志性事件,毒舌语录库,经典翻车现场 entries chronologically by date.
|
- Sort 经典金句,标志性事件,毒舌语录库,经典翻车现场 entries chronologically by date.
|
||||||
- No cap on the size of append-only sections during backfill — let history flow in.
|
- No cap on the size of append-only sections during backfill — let history flow in.
|
||||||
|
|
||||||
@@ -204,6 +218,9 @@ Triggered when the user says `回溯画像`, `初始化画像`, `backfill profil
|
|||||||
- `last_seen` = latest digest date the user appeared in.
|
- `last_seen` = latest digest date the user appeared in.
|
||||||
- `total_messages` = sum of per-digest counts.
|
- `total_messages` = sum of per-digest counts.
|
||||||
- `digest_appearances` = number of digests the user crossed the 3-message threshold in.
|
- `digest_appearances` = number of digests the user crossed the 3-message threshold in.
|
||||||
|
- `group_nicknames` = best-effort. If the same `wxid` appears under multiple distinct display names across historical digests (e.g., via the leaderboard line "X — N 条" where X varied), fill the older ones in chronological order (newest stays in `name`). If chronological order is unclear, dedupe and let later runs correct.
|
||||||
|
- `aliases` = best-effort. Scan historical digest bodies for forms where another member calls this user by a name different from their current `name` (@mentions, direct salutations). Skip uncertain matches; leave `[]` if nothing reliable surfaces.
|
||||||
|
- `tags` = `[]`. Backfill does not seed `tags`; let normal runs accumulate them.
|
||||||
|
|
||||||
7. **Report.** After both passes complete, print a short summary:
|
7. **Report.** After both passes complete, print a short summary:
|
||||||
- `Backfilled {N} normal profiles from {M} digests.`
|
- `Backfilled {N} normal profiles from {M} digests.`
|
||||||
@@ -264,7 +281,8 @@ When loading profile context for a fresh digest:
|
|||||||
2. For the normal pass, read `profiles/{wxid}-*.md` for each. Skip if missing.
|
2. For the normal pass, read `profiles/{wxid}-*.md` for each. Skip if missing.
|
||||||
3. If the current run also generates the roast version, **separately** read `profiles-roast/{wxid}-*.md` during the roast generation pass.
|
3. If the current run also generates the roast version, **separately** read `profiles-roast/{wxid}-*.md` during the roast generation pass.
|
||||||
4. Compile a condensed working-memory block:
|
4. Compile a condensed working-memory block:
|
||||||
- The user's current `name` and `aliases` (so you can recognize them under different names).
|
- The user's current `name`, `group_nicknames`, and `aliases` (so you can recognize them under prior display names or community-given nicknames).
|
||||||
|
- `tags` (cross-cutting attributes — region, profession, community — useful for callouts in 群友画像).
|
||||||
- 角色标签 / 人设标签 (so you can carry forward or contrast).
|
- 角色标签 / 人设标签 (so you can carry forward or contrast).
|
||||||
- The 3-5 most recent 经典金句 / 毒舌语录 entries (so you can detect callbacks and repeats).
|
- The 3-5 most recent 经典金句 / 毒舌语录 entries (so you can detect callbacks and repeats).
|
||||||
- The 3-5 most recent 标志性事件 / 翻车现场 entries (so you can spot recurring themes).
|
- The 3-5 most recent 标志性事件 / 翻车现场 entries (so you can spot recurring themes).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: baoyu-xhs-images
|
name: baoyu-xhs-images
|
||||||
description: "[Deprecated: use baoyu-image-cards] Generates Xiaohongshu (Little Red Book) image card series with 12 visual styles, 8 layouts, and 3 color palettes. Breaks content into 1-10 cartoon-style image cards optimized for XHS engagement. Use when user mentions \"小红书图片\", \"XHS images\", \"RedNote infographics\", \"小红书种草\", \"小绿书\", \"微信图文\", \"微信贴图\", or wants social media infographic series for Chinese platforms."
|
description: "[Deprecated: use baoyu-image-cards] Generates Xiaohongshu (Little Red Book) image card series with 12 visual styles, 8 layouts, and 3 color palettes. Breaks content into 1-10 cartoon-style image cards optimized for XHS engagement. Use when user mentions \"小红书图片\", \"XHS images\", \"RedNote infographics\", \"小红书种草\", \"小绿书\", \"微信图文\", \"微信贴图\", or wants social media infographic series for Chinese platforms."
|
||||||
version: 1.56.2
|
version: 1.57.0
|
||||||
metadata:
|
metadata:
|
||||||
openclaw:
|
openclaw:
|
||||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-xhs-images
|
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-xhs-images
|
||||||
@@ -37,12 +37,31 @@ When this skill needs to render an image, resolve the backend in this order:
|
|||||||
|
|
||||||
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
|
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
|
||||||
|
|
||||||
|
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace titles, body copy, tags, or any other text inside an already generated image card. If text is wrong or unclear, regenerate from a corrected prompt, switch to a layout with less on-card text, or ask the user which imperfect candidate to keep.
|
||||||
|
|
||||||
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
|
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
|
||||||
|
|
||||||
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The file is the reproducibility record and lets you switch backends without regenerating prompts.
|
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The file is the reproducibility record and lets you switch backends without regenerating prompts.
|
||||||
|
|
||||||
Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) above are examples — substitute the local equivalents under the same rule.
|
Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) above are examples — substitute the local equivalents under the same rule.
|
||||||
|
|
||||||
|
## Batch Generation Policy
|
||||||
|
|
||||||
|
After every prompt file for the current generation group has been saved and verified, generate images in batches by default.
|
||||||
|
|
||||||
|
Priority order:
|
||||||
|
|
||||||
|
1. Use the chosen backend's native batch / multi-task interface if it exists. Each task must keep its own prompt file, output path, aspect ratio, session ID, and direct reference images.
|
||||||
|
2. If no native batch interface exists but the runtime can issue parallel tool calls, dispatch up to `generation_batch_size` images at a time. Default: `4`. An explicit user request in the current message, such as `--batch-size 4` or "并行4张一起生成", overrides EXTEND.md.
|
||||||
|
3. If neither native batch nor parallel tool calls are available, generate sequentially.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Honor the image-1 anchor chain: generate image 1 first, then batch images 2+ using image 1 as the reference.
|
||||||
|
- Never start a batch until every selected prompt file for that batch exists on disk.
|
||||||
|
- Retry failed items once without regenerating successful items.
|
||||||
|
- Do not use subagents merely to parallelize image rendering. Use subagents only for separate prompt iteration or creative exploration.
|
||||||
|
|
||||||
## Confirmation Policy
|
## Confirmation Policy
|
||||||
|
|
||||||
Default behavior: **confirm before generation**.
|
Default behavior: **confirm before generation**.
|
||||||
@@ -65,6 +84,7 @@ Respond in the user's language across questions, progress, errors, and completio
|
|||||||
| `--palette <name>` | Color override: macaron / warm / neon |
|
| `--palette <name>` | Color override: macaron / warm / neon |
|
||||||
| `--preset <name>` | Style + layout + optional palette shorthand (see Presets below; per-preset prompt fragments in `references/style-presets.md`) |
|
| `--preset <name>` | Style + layout + optional palette shorthand (see Presets below; per-preset prompt fragments in `references/style-presets.md`) |
|
||||||
| `--ref <files...>` | Reference images applied to image 1 as the series anchor |
|
| `--ref <files...>` | Reference images applied to image 1 as the series anchor |
|
||||||
|
| `--batch-size <n>` | Temporary generation batch size for this run. Default: `generation_batch_size` from EXTEND.md, otherwise 4. Clamp to 1-8. |
|
||||||
| `--yes` | Non-interactive: skip all confirmations, use EXTEND.md or built-in defaults, auto-confirm recommended plan (Path A) |
|
| `--yes` | Non-interactive: skip all confirmations, use EXTEND.md or built-in defaults, auto-confirm recommended plan (Path A) |
|
||||||
|
|
||||||
## Dimensions
|
## Dimensions
|
||||||
@@ -300,7 +320,7 @@ Check these paths in order; first hit wins:
|
|||||||
- **Not found + interactive** → run first-time setup (see `references/config/first-time-setup.md`) and save before anything else. Do NOT analyze content or ask style questions until preferences exist — this keeps first-run behavior predictable.
|
- **Not found + interactive** → run first-time setup (see `references/config/first-time-setup.md`) and save before anything else. Do NOT analyze content or ask style questions until preferences exist — this keeps first-run behavior predictable.
|
||||||
- **Not found + `--yes`** → skip setup, use built-in defaults (no watermark, style/layout auto-selected, language from content). Do not prompt, do not create EXTEND.md.
|
- **Not found + `--yes`** → skip setup, use built-in defaults (no watermark, style/layout auto-selected, language from content). Do not prompt, do not create EXTEND.md.
|
||||||
|
|
||||||
**EXTEND.md keys**: watermark, preferred style/layout, custom style definitions, language preference. Schema: `references/config/preferences-schema.md`.
|
**EXTEND.md keys**: watermark, preferred style/layout, custom style definitions, language preference, preferred image backend, generation batch size. Schema: `references/config/preferences-schema.md`.
|
||||||
|
|
||||||
### Step 1: Analyze Content → `analysis.md`
|
### Step 1: Analyze Content → `analysis.md`
|
||||||
|
|
||||||
@@ -349,14 +369,13 @@ With confirmed outline + style + layout + palette:
|
|||||||
|
|
||||||
**Visual consistency — image-1 anchor chain**: character / mascot / color rendering drifts between calls unless you anchor them. Generate image 1 (cover) first WITHOUT `--ref`, then pass image 1 as `--ref` to every subsequent image. This is the single most important consistency trick for this skill — don't skip it even if the backend also supports a session ID.
|
**Visual consistency — image-1 anchor chain**: character / mascot / color rendering drifts between calls unless you anchor them. Generate image 1 (cover) first WITHOUT `--ref`, then pass image 1 as `--ref` to every subsequent image. This is the single most important consistency trick for this skill — don't skip it even if the backend also supports a session ID.
|
||||||
|
|
||||||
For each image (cover, content, ending):
|
Generation flow:
|
||||||
|
|
||||||
1. Write the full prompt to `prompts/NN-{type}-{slug}.md` in the user's preferred language (backup rule applies).
|
1. Write the full prompt for every image to `prompts/NN-{type}-{slug}.md` in the user's preferred language (backup rule applies), then verify all selected prompt files exist.
|
||||||
2. Generate:
|
2. Generate **image 1** first without `--ref`; backup rule applies to the PNG file. This establishes the anchor.
|
||||||
- **Image 1**: no `--ref` (establishes the anchor).
|
3. Build a task list for **images 2+** using image 1 as `--ref <path-to-image-01.png>`.
|
||||||
- **Images 2+**: add `--ref <path-to-image-01.png>`.
|
4. Dispatch images 2+ in batches per the `## Batch Generation Policy`: backend native batch first, runtime parallel tool calls second, sequential only as fallback.
|
||||||
- Backup rule applies to the PNG files.
|
5. Report progress after each completed image. On failure, retry only the failed item once from the same saved prompt file.
|
||||||
3. Report progress after each image.
|
|
||||||
|
|
||||||
**Watermark** (if enabled in EXTEND.md): append to the generation prompt:
|
**Watermark** (if enabled in EXTEND.md): append to the generation prompt:
|
||||||
|
|
||||||
@@ -415,6 +434,12 @@ For the style × layout compatibility matrix, see the **Style × Layout Matrix**
|
|||||||
|
|
||||||
Always update the prompt file before regenerating — it's the source of truth and makes changes reproducible.
|
Always update the prompt file before regenerating — it's the source of truth and makes changes reproducible.
|
||||||
|
|
||||||
|
Text correction policy:
|
||||||
|
|
||||||
|
- If a card's title, body copy, tags, or any other rendered text is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
|
||||||
|
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
|
||||||
|
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
| File | Content |
|
| File | Content |
|
||||||
@@ -451,5 +476,6 @@ EXTEND.md lives at the first matching path listed in Step 0. Three ways to chang
|
|||||||
- `preferred_image_backend: codex-imagegen` — pin to Codex's built-in.
|
- `preferred_image_backend: codex-imagegen` — pin to Codex's built-in.
|
||||||
- `preferred_image_backend: baoyu-imagine` — pin to the baoyu-imagine skill.
|
- `preferred_image_backend: baoyu-imagine` — pin to the baoyu-imagine skill.
|
||||||
- `preferred_image_backend: ask` — confirm backend every run.
|
- `preferred_image_backend: ask` — confirm backend every run.
|
||||||
|
- `generation_batch_size: 4` — default number of images to render concurrently when the backend/runtime supports batch or parallel generation.
|
||||||
- `preferred_style: notion`, `preferred_layout: dense`, `preferred_palette: macaron`, `language: zh`.
|
- `preferred_style: notion`, `preferred_layout: dense`, `preferred_palette: macaron`, `language: zh`.
|
||||||
- `watermark.enabled: true` + `watermark.content: "@handle"` — add a watermark.
|
- `watermark.enabled: true` + `watermark.content: "@handle"` — add a watermark.
|
||||||
|
|||||||
@@ -111,12 +111,15 @@ preferred_style:
|
|||||||
preferred_layout: null
|
preferred_layout: null
|
||||||
language: null
|
language: null
|
||||||
preferred_image_backend: auto
|
preferred_image_backend: auto
|
||||||
|
generation_batch_size: 4
|
||||||
custom_styles: []
|
custom_styles: []
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
`preferred_image_backend: auto` is the baked-in default — first-time setup does not ask about it. The `## Image Generation Tools` rule in SKILL.md then picks the runtime-native tool (Codex `imagegen`, Hermes `image_generate`, etc.) when available, and falls back to installed backends.
|
`preferred_image_backend: auto` is the baked-in default — first-time setup does not ask about it. The `## Image Generation Tools` rule in SKILL.md then picks the runtime-native tool (Codex `imagegen`, Hermes `image_generate`, etc.) when available, and falls back to installed backends.
|
||||||
|
|
||||||
|
`generation_batch_size: 4` is the baked-in default for batch rendering. The current user request may override it for one run.
|
||||||
|
|
||||||
## Modifying Preferences Later
|
## Modifying Preferences Later
|
||||||
|
|
||||||
See the `## Changing Preferences` section in `SKILL.md` for the canonical list of common edits (pin backend, change defaults, retrigger setup). Full schema: `preferences-schema.md`.
|
See the `## Changing Preferences` section in `SKILL.md` for the canonical list of common edits (pin backend, change defaults, retrigger setup). Full schema: `preferences-schema.md`.
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ language: null # zh|en|ja|ko|auto
|
|||||||
|
|
||||||
preferred_image_backend: auto # auto|ask|<backend-id>
|
preferred_image_backend: auto # auto|ask|<backend-id>
|
||||||
|
|
||||||
|
generation_batch_size: 4 # 1-8, used when backend/runtime supports batch or parallel generation
|
||||||
|
|
||||||
custom_styles:
|
custom_styles:
|
||||||
- name: my-style
|
- name: my-style
|
||||||
description: "Style description"
|
description: "Style description"
|
||||||
@@ -52,6 +54,7 @@ custom_styles:
|
|||||||
| `preferred_layout` | string | null | Layout preference or null |
|
| `preferred_layout` | string | null | Layout preference or null |
|
||||||
| `language` | string | null | Output language (null = auto-detect) |
|
| `language` | string | null | Output language (null = auto-detect) |
|
||||||
| `preferred_image_backend` | string | `auto` | Image backend selection. `auto` = prefer runtime-native tool, fall back to the only installed backend, ask if multiple non-native are present. `ask` = always confirm on every run. `<backend-id>` (e.g., `codex-imagegen`, `baoyu-imagine`, `image_generate`) = pin this backend when available; fall back to `auto` when it isn't. Absent = `auto`. Resolution logic is documented in `SKILL.md`'s `## Image Generation Tools` section. |
|
| `preferred_image_backend` | string | `auto` | Image backend selection. `auto` = prefer runtime-native tool, fall back to the only installed backend, ask if multiple non-native are present. `ask` = always confirm on every run. `<backend-id>` (e.g., `codex-imagegen`, `baoyu-imagine`, `image_generate`) = pin this backend when available; fall back to `auto` when it isn't. Absent = `auto`. Resolution logic is documented in `SKILL.md`'s `## Image Generation Tools` section. |
|
||||||
|
| `generation_batch_size` | int | 4 | Number of images to dispatch per batch when the backend has native batch support or the runtime can issue parallel generation calls. Clamp invalid values to 1-8. Current user request overrides this value. |
|
||||||
| `custom_styles` | array | [] | User-defined styles |
|
| `custom_styles` | array | [] | User-defined styles |
|
||||||
|
|
||||||
## Position Options
|
## Position Options
|
||||||
@@ -109,6 +112,8 @@ language: zh
|
|||||||
|
|
||||||
preferred_image_backend: codex-imagegen
|
preferred_image_backend: codex-imagegen
|
||||||
|
|
||||||
|
generation_batch_size: 4
|
||||||
|
|
||||||
custom_styles:
|
custom_styles:
|
||||||
- name: corporate
|
- name: corporate
|
||||||
description: "Professional B2B style"
|
description: "Professional B2B style"
|
||||||
|
|||||||
Reference in New Issue
Block a user