Compare commits

...

8 Commits

Author SHA1 Message Date
Jim Liu 宝玉 6a71d5080d chore: release v1.62.0 2026-03-12 01:51:18 -05:00
Jim Liu 宝玉 1e6e6637ac feat(baoyu-infographic): support flexible aspect ratios
Accept custom W:H ratios (e.g., 3:4, 4:3, 2.35:1) in addition to
named presets (landscape/portrait/square).
2026-03-12 01:50:36 -05:00
Jim Liu 宝玉 873b60aee5 docs(wechat): replace credential-like placeholders 2026-03-12 00:53:33 -05:00
Jim Liu 宝玉 b88ac59133 fix: set strict mode on plugins to prevent duplicated slash commands
Closes #79
2026-03-11 23:29:27 -05:00
Jim Liu 宝玉 d889c04084 chore: release v1.61.0 2026-03-11 23:24:43 -05:00
Jim Liu 宝玉 5276fae6bd feat(baoyu-post-to-wechat): add multi-account support
Support multiple WeChat Official Accounts via EXTEND.md accounts block.
Each account gets isolated Chrome profile, credential resolution chain
(inline → prefixed env → unprefixed env), and --account CLI arg.
2026-03-11 23:24:02 -05:00
Jim Liu 宝玉 c0941f8089 fix: exclude out/dist/build dirs and bun.lockb from skill release files 2026-03-11 22:55:47 -05:00
Jim Liu 宝玉 42b8b1fc99 fix: use proper MIME types in skill publish to fix ClawhHub rejection 2026-03-11 22:46:06 -05:00
16 changed files with 612 additions and 69 deletions
+4 -4
View File
@@ -6,14 +6,14 @@
}, },
"metadata": { "metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency", "description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.60.0" "version": "1.62.0"
}, },
"plugins": [ "plugins": [
{ {
"name": "content-skills", "name": "content-skills",
"description": "Content generation and publishing skills", "description": "Content generation and publishing skills",
"source": "./", "source": "./",
"strict": false, "strict": true,
"skills": [ "skills": [
"./skills/baoyu-xhs-images", "./skills/baoyu-xhs-images",
"./skills/baoyu-post-to-x", "./skills/baoyu-post-to-x",
@@ -30,7 +30,7 @@
"name": "ai-generation-skills", "name": "ai-generation-skills",
"description": "AI-powered generation backends", "description": "AI-powered generation backends",
"source": "./", "source": "./",
"strict": false, "strict": true,
"skills": [ "skills": [
"./skills/baoyu-danger-gemini-web", "./skills/baoyu-danger-gemini-web",
"./skills/baoyu-image-gen" "./skills/baoyu-image-gen"
@@ -40,7 +40,7 @@
"name": "utility-skills", "name": "utility-skills",
"description": "Utility tools for content processing", "description": "Utility tools for content processing",
"source": "./", "source": "./",
"strict": false, "strict": true,
"skills": [ "skills": [
"./skills/baoyu-danger-x-to-markdown", "./skills/baoyu-danger-x-to-markdown",
"./skills/baoyu-compress-image", "./skills/baoyu-compress-image",
+20
View File
@@ -2,6 +2,26 @@
English | [中文](./CHANGELOG.zh.md) English | [中文](./CHANGELOG.zh.md)
## 1.62.0 - 2026-03-12
### Features
- `baoyu-infographic`: support flexible aspect ratios with custom W:H values (e.g., 3:4, 4:3, 2.35:1) in addition to named presets
### Fixes
- Set strict mode on plugins to prevent duplicated slash commands
### Documentation
- `baoyu-post-to-wechat`: replace credential-like placeholders
## 1.61.0 - 2026-03-11
### Features
- `baoyu-post-to-wechat`: add multi-account support with `--account` CLI arg, EXTEND.md accounts block, isolated Chrome profiles, and credential resolution chain
### Fixes
- Exclude `out/dist/build` dirs and `bun.lockb` from skill release files
- Use proper MIME types in skill publish to fix ClawhHub rejection
## 1.60.0 - 2026-03-11 ## 1.60.0 - 2026-03-11
### Features ### Features
+20
View File
@@ -2,6 +2,26 @@
[English](./CHANGELOG.md) | 中文 [English](./CHANGELOG.md) | 中文
## 1.62.0 - 2026-03-12
### 新功能
- `baoyu-infographic`:支持灵活宽高比,可使用自定义 W:H 值(如 3:4、4:3、2.35:1),同时保留预设名称
### 修复
- 设置插件严格模式,防止重复注册斜杠命令
### 文档
- `baoyu-post-to-wechat`:替换类似凭证的占位符
## 1.61.0 - 2026-03-11
### 新功能
- `baoyu-post-to-wechat`:新增多账号支持,通过 `--account` 参数选择账号,EXTEND.md 支持 accounts 配置块,每个账号独立 Chrome 配置目录和凭证解析链
### 修复
- 排除 `out/dist/build` 目录和 `bun.lockb` 文件,避免打包到技能发布文件中
- 修复技能发布时 MIME 类型不正确导致 ClawhHub 拒绝的问题
## 1.60.0 - 2026-03-11 ## 1.60.0 - 2026-03-11
### 新功能 ### 新功能
+1 -1
View File
@@ -1,6 +1,6 @@
# CLAUDE.md # CLAUDE.md
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.60.0**. Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.61.0**.
## Architecture ## Architecture
+44 -2
View File
@@ -169,8 +169,9 @@ Generate professional infographics with 20 layout types and 17 visual styles. An
# Specify both # Specify both
/baoyu-infographic path/to/content.md --layout funnel --style corporate-memphis /baoyu-infographic path/to/content.md --layout funnel --style corporate-memphis
# With aspect ratio # With aspect ratio (named preset or custom W:H)
/baoyu-infographic path/to/content.md --aspect portrait /baoyu-infographic path/to/content.md --aspect portrait
/baoyu-infographic path/to/content.md --aspect 3:4
``` ```
**Options**: **Options**:
@@ -178,7 +179,7 @@ Generate professional infographics with 20 layout types and 17 visual styles. An
|--------|-------------| |--------|-------------|
| `--layout <name>` | Information layout (20 options) | | `--layout <name>` | Information layout (20 options) |
| `--style <name>` | Visual style (17 options, default: craft-handmade) | | `--style <name>` | Visual style (17 options, default: craft-handmade) |
| `--aspect <ratio>` | landscape (16:9), portrait (9:16), square (1:1) | | `--aspect <ratio>` | Named: landscape (16:9), portrait (9:16), square (1:1). Custom: any W:H ratio (e.g., 3:4, 4:3, 2.35:1) |
| `--lang <code>` | Output language (en, zh, ja, etc.) | | `--lang <code>` | Output language (en, zh, ja, etc.) |
**Layouts** (information structure): **Layouts** (information structure):
@@ -581,6 +582,47 @@ 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).
**Multi-Account Support**: Manage multiple WeChat Official Accounts via `EXTEND.md`:
```bash
mkdir -p .baoyu-skills/baoyu-post-to-wechat
```
Create `.baoyu-skills/baoyu-post-to-wechat/EXTEND.md`:
```yaml
# Global settings (shared across all accounts)
default_theme: default
default_color: blue
# Account list
accounts:
- name: My Tech Blog
alias: tech-blog
default: false
default_publish_method: api
default_author: Author Name
need_open_comment: 1
only_fans_can_comment: 0
app_id: your_wechat_app_id
app_secret: your_wechat_app_secret
- name: AI Newsletter
alias: ai-news
default_publish_method: browser
default_author: AI Newsletter
need_open_comment: 1
only_fans_can_comment: 0
```
| Accounts configured | Behavior |
|---------------------|----------|
| No `accounts` block | Single-account mode (backward compatible) |
| 1 account | Auto-select, no prompt |
| 2+ accounts | Prompt to select, or use `--account <alias>` |
| 1 account has `default: true` | Pre-selected as default |
Each account gets an isolated Chrome profile for independent login sessions (browser method). API credentials can be set inline in EXTEND.md or via `.env` with alias-prefixed keys (e.g., `WECHAT_TECH_BLOG_APP_ID`).
#### baoyu-post-to-weibo #### baoyu-post-to-weibo
Post content to Weibo (微博). Supports regular posts with text, images, and videos, and headline articles (头条文章) with Markdown input. Uses real Chrome with CDP to bypass anti-automation. Post content to Weibo (微博). Supports regular posts with text, images, and videos, and headline articles (头条文章) with Markdown input. Uses real Chrome with CDP to bypass anti-automation.
+44 -2
View File
@@ -169,8 +169,9 @@ clawhub install baoyu-markdown-to-html
# 同时指定布局和风格 # 同时指定布局和风格
/baoyu-infographic path/to/content.md --layout funnel --style corporate-memphis /baoyu-infographic path/to/content.md --layout funnel --style corporate-memphis
# 指定比例 # 指定比例(预设名称或自定义 W:H
/baoyu-infographic path/to/content.md --aspect portrait /baoyu-infographic path/to/content.md --aspect portrait
/baoyu-infographic path/to/content.md --aspect 3:4
``` ```
**选项** **选项**
@@ -178,7 +179,7 @@ clawhub install baoyu-markdown-to-html
|------|------| |------|------|
| `--layout <name>` | 信息布局(20 种选项) | | `--layout <name>` | 信息布局(20 种选项) |
| `--style <name>` | 视觉风格(17 种选项,默认:craft-handmade | | `--style <name>` | 视觉风格(17 种选项,默认:craft-handmade |
| `--aspect <ratio>` | landscape (16:9)、portrait (9:16)、square (1:1) | | `--aspect <ratio>` | 预设:landscape (16:9)、portrait (9:16)、square (1:1)。自定义:任意 W:H 比例(如 3:4、4:3、2.35:1 |
| `--lang <code>` | 输出语言(en、zh、ja 等) | | `--lang <code>` | 输出语言(en、zh、ja 等) |
**布局**(信息结构): **布局**(信息结构):
@@ -581,6 +582,47 @@ WECHAT_APP_SECRET=你的AppSecret
**浏览器方式**(无需 API 配置):需已安装 Google Chrome,首次运行需扫码登录(登录状态会保存) **浏览器方式**(无需 API 配置):需已安装 Google Chrome,首次运行需扫码登录(登录状态会保存)
**多账号支持**:通过 `EXTEND.md` 管理多个微信公众号:
```bash
mkdir -p .baoyu-skills/baoyu-post-to-wechat
```
创建 `.baoyu-skills/baoyu-post-to-wechat/EXTEND.md`
```yaml
# 全局设置(所有账号共享)
default_theme: default
default_color: blue
# 账号列表
accounts:
- name: 宝玉的技术分享
alias: baoyu
default: false
default_publish_method: api
default_author: 宝玉
need_open_comment: 1
only_fans_can_comment: 0
app_id: 你的微信AppID
app_secret: 你的微信AppSecret
- name: AI 工具集
alias: ai-tools
default_publish_method: browser
default_author: AI 工具集
need_open_comment: 1
only_fans_can_comment: 0
```
| 账号配置情况 | 行为 |
|-------------|------|
| 无 `accounts` 块 | 单账号模式(向后兼容) |
| 1 个账号 | 自动选择,无需提示 |
| 2+ 个账号 | 提示选择,或使用 `--account <别名>` |
| 某账号设置 `default: true` | 预选为默认账号 |
每个账号拥有独立的 Chrome 配置目录,保证浏览器方式下的登录会话互不干扰。API 凭证可在 EXTEND.md 中直接配置,也可通过 `.env` 文件使用别名前缀的环境变量(如 `WECHAT_BAOYU_APP_ID`)。
#### baoyu-post-to-weibo #### baoyu-post-to-weibo
发布内容到微博。支持文字、图片、视频发布和头条文章(长篇 Markdown)。使用真实 Chrome + CDP 绕过反自动化检测。 发布内容到微博。支持文字、图片、视频发布和头条文章(长篇 Markdown)。使用真实 Chrome + CDP 绕过反自动化检测。
+2 -2
View File
@@ -8,8 +8,8 @@ const PACKAGE_DEPENDENCY_SECTIONS = [
"devDependencies", "devDependencies",
]; ];
const SKIPPED_DIRS = new Set([".git", ".clawhub", ".clawdhub", "node_modules"]); const SKIPPED_DIRS = new Set([".git", ".clawhub", ".clawdhub", "node_modules", "out", "dist", "build"]);
const SKIPPED_FILES = new Set([".DS_Store"]); const SKIPPED_FILES = new Set([".DS_Store", "bun.lockb"]);
export async function listReleaseFiles(root) { export async function listReleaseFiles(root) {
const resolvedRoot = path.resolve(root); const resolvedRoot = path.resolve(root);
+21 -1
View File
@@ -220,7 +220,7 @@ async function publishSkill({ registry, token, skill, files, version, changelog,
); );
for (const file of files) { for (const file of files) {
form.append("files", new Blob([file.bytes], { type: "application/octet-stream" }), file.relPath); form.append("files", new Blob([file.bytes], { type: mimeType(file.relPath) }), file.relPath);
} }
const response = await fetch(`${registry}/api/v1/skills`, { const response = await fetch(`${registry}/api/v1/skills`, {
@@ -281,6 +281,26 @@ function titleCase(value) {
.replace(/\b\w/g, (char) => char.toUpperCase()); .replace(/\b\w/g, (char) => char.toUpperCase());
} }
const MIME_MAP = {
".md": "text/markdown",
".ts": "text/plain",
".js": "text/javascript",
".mjs": "text/javascript",
".json": "application/json",
".yml": "text/yaml",
".yaml": "text/yaml",
".txt": "text/plain",
".html": "text/html",
".css": "text/css",
".xml": "text/xml",
".svg": "image/svg+xml",
};
function mimeType(relPath) {
const ext = path.extname(relPath).toLowerCase();
return MIME_MAP[ext] || "text/plain";
}
function parseBoolean(value) { function parseBoolean(value) {
return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase()); return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
} }
+7 -2
View File
@@ -17,6 +17,7 @@ Two dimensions: **layout** (information structure) × **style** (visual aestheti
/baoyu-infographic path/to/content.md /baoyu-infographic path/to/content.md
/baoyu-infographic path/to/content.md --layout hierarchical-layers --style technical-schematic /baoyu-infographic path/to/content.md --layout hierarchical-layers --style technical-schematic
/baoyu-infographic path/to/content.md --aspect portrait --lang zh /baoyu-infographic path/to/content.md --aspect portrait --lang zh
/baoyu-infographic path/to/content.md --aspect 3:4
/baoyu-infographic # then paste content /baoyu-infographic # then paste content
``` ```
@@ -26,7 +27,7 @@ Two dimensions: **layout** (information structure) × **style** (visual aestheti
|--------|--------| |--------|--------|
| `--layout` | 21 options (see Layout Gallery), default: bento-grid | | `--layout` | 21 options (see Layout Gallery), default: bento-grid |
| `--style` | 20 options (see Style Gallery), default: craft-handmade | | `--style` | 20 options (see Style Gallery), default: craft-handmade |
| `--aspect` | landscape (16:9), portrait (9:16), square (1:1) | | `--aspect` | Named: landscape (16:9), portrait (9:16), square (1:1). Custom: any W:H ratio (e.g., 3:4, 4:3, 2.35:1) |
| `--lang` | en, zh, ja, etc. | | `--lang` | en, zh, ja, etc. |
## Layout Gallery ## Layout Gallery
@@ -220,7 +221,7 @@ Use **single AskUserQuestion call** with multiple questions to confirm all optio
| Question | When | Options | | Question | When | Options |
|----------|------|---------| |----------|------|---------|
| **Combination** | Always | 3+ layout×style combos with rationale | | **Combination** | Always | 3+ layout×style combos with rationale |
| **Aspect** | Always | landscape (16:9), portrait (9:16), square (1:1) | | **Aspect** | Always | Named presets (landscape/portrait/square) or custom W:H ratio (e.g., 3:4, 4:3, 2.35:1) |
| **Language** | Only if source ≠ user language | Language for text content | | **Language** | Only if source ≠ user language | Language for text content |
**Important**: Do NOT split into separate AskUserQuestion calls. Combine all applicable questions into one call. **Important**: Do NOT split into separate AskUserQuestion calls. Combine all applicable questions into one call.
@@ -236,6 +237,10 @@ Combine:
4. Structured content from Step 2 4. Structured content from Step 2
5. All text in confirmed language 5. All text in confirmed language
**Aspect ratio resolution** for `{{ASPECT_RATIO}}`:
- Named presets → ratio string: landscape→`16:9`, portrait→`9:16`, square→`1:1`
- Custom W:H ratios → use as-is (e.g., `3:4`, `4:3`, `2.35:1`)
### Step 6: Generate Image ### Step 6: Generate Image
1. Select available image generation skill (ask user if multiple) 1. Select available image generation skill (ask user if multiple)
+108 -1
View File
@@ -95,9 +95,115 @@ chrome_profile_path: /path/to/chrome/profile
**Value priority**: **Value priority**:
1. CLI arguments 1. CLI arguments
2. Frontmatter 2. Frontmatter
3. EXTEND.md 3. EXTEND.md (account-level → global-level)
4. Skill defaults 4. Skill defaults
## Multi-Account Support
EXTEND.md supports managing multiple WeChat Official Accounts. When `accounts:` block is present, each account can have its own credentials, Chrome profile, and default settings.
**Compatibility rules**:
| Condition | Mode | Behavior |
|-----------|------|----------|
| No `accounts` block | Single-account | Current behavior, unchanged |
| `accounts` with 1 entry | Single-account | Auto-select, no prompt |
| `accounts` with 2+ entries | Multi-account | Prompt to select before publishing |
| `accounts` with `default: true` | Multi-account | Pre-select default, user can switch |
**Multi-account EXTEND.md example**:
```md
default_theme: default
default_color: blue
accounts:
- name: 宝玉的技术分享
alias: baoyu
default: true
default_publish_method: api
default_author: 宝玉
need_open_comment: 1
only_fans_can_comment: 0
app_id: your_wechat_app_id
app_secret: your_wechat_app_secret
- name: AI工具集
alias: ai-tools
default_publish_method: browser
default_author: AI工具集
need_open_comment: 1
only_fans_can_comment: 0
```
**Per-account keys** (can be set per-account or globally as fallback):
`default_publish_method`, `default_author`, `need_open_comment`, `only_fans_can_comment`, `app_id`, `app_secret`, `chrome_profile_path`
**Global-only keys** (always shared across accounts):
`default_theme`, `default_color`
### Account Selection (Step 0.5)
Insert between Step 0 and Step 1 in the Article Posting Workflow:
```
if no accounts block:
→ single-account mode (current behavior)
elif accounts.length == 1:
→ auto-select the only account
elif --account <alias> CLI arg:
→ select matching account
elif one account has default: true:
→ pre-select, show: "Using account: <name> (--account to switch)"
else:
→ prompt user:
"Multiple WeChat accounts configured:
1) <name1> (<alias1>)
2) <name2> (<alias2>)
Select account [1-N]:"
```
### Credential Resolution (API Method)
For a selected account with alias `{alias}`:
1. `app_id` / `app_secret` inline in EXTEND.md account block
2. Env var `WECHAT_{ALIAS}_APP_ID` / `WECHAT_{ALIAS}_APP_SECRET` (alias uppercased, hyphens → underscores)
3. `.baoyu-skills/.env` with prefixed key `WECHAT_{ALIAS}_APP_ID`
4. `~/.baoyu-skills/.env` with prefixed key
5. Fallback to unprefixed `WECHAT_APP_ID` / `WECHAT_APP_SECRET`
**.env multi-account example**:
```bash
# Account: baoyu
WECHAT_BAOYU_APP_ID=your_wechat_app_id
WECHAT_BAOYU_APP_SECRET=your_wechat_app_secret
# Account: ai-tools
WECHAT_AI_TOOLS_APP_ID=your_ai_tools_wechat_app_id
WECHAT_AI_TOOLS_APP_SECRET=your_ai_tools_wechat_app_secret
```
### Chrome Profile (Browser Method)
Each account uses an isolated Chrome profile for independent login sessions:
| Source | Path |
|--------|------|
| Account `chrome_profile_path` in EXTEND.md | Use as-is |
| Auto-generated from alias | `{shared_profile_parent}/wechat-{alias}/` |
| Single-account fallback | Shared default profile (current behavior) |
### CLI `--account` Argument
All publishing scripts accept `--account <alias>`:
```bash
${BUN_X} {baseDir}/scripts/wechat-api.ts <file> --theme default --account ai-tools
${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
```
## Pre-flight Check (Optional) ## Pre-flight Check (Optional)
Before first use, suggest running the environment check. User can skip if they prefer. Before first use, suggest running the environment check. User can skip if they prefer.
@@ -139,6 +245,7 @@ Copy this checklist and check off items as you complete them:
``` ```
Publishing Progress: Publishing Progress:
- [ ] Step 0: Load preferences (EXTEND.md) - [ ] Step 0: Load preferences (EXTEND.md)
- [ ] Step 0.5: Resolve account (multi-account only)
- [ ] Step 1: Determine input type - [ ] Step 1: Determine input type
- [ ] Step 2: Select method and configure credentials - [ ] Step 2: Select method and configure credentials
- [ ] Step 3: Resolve theme/color and validate metadata - [ ] Step 3: Resolve theme/color and validate metadata
@@ -152,6 +152,8 @@ options:
## EXTEND.md Template ## EXTEND.md Template
### Single Account (Default)
```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]
@@ -162,6 +164,40 @@ only_fans_can_comment: [1/0]
chrome_profile_path: chrome_profile_path:
``` ```
### Multi-Account
```md
default_theme: [default/grace/simple/modern]
default_color: [preset name, hex, or empty for theme default]
accounts:
- name: [display name]
alias: [short key, e.g. "baoyu"]
default: true
default_publish_method: [api/browser]
default_author: [author name]
need_open_comment: [1/0]
only_fans_can_comment: [1/0]
app_id: [WeChat App ID, optional]
app_secret: [WeChat App Secret, optional]
- name: [second account name]
alias: [short key, e.g. "ai-tools"]
default_publish_method: [api/browser]
default_author: [author name]
need_open_comment: [1/0]
only_fans_can_comment: [1/0]
```
## Adding More Accounts Later
After initial setup, users can add accounts by editing EXTEND.md:
1. Add an `accounts:` block with list items
2. Move per-account settings (author, publish method, comments) into each account entry
3. Keep global settings (theme, color) at the top level
4. Each account needs a unique `alias` (used for CLI `--account` arg and Chrome profile naming)
5. Set `default: true` on the primary account
## Modifying Preferences Later ## Modifying Preferences Later
Users can edit EXTEND.md directly or delete it to trigger setup again. Users can edit EXTEND.md directly or delete it to trigger setup again.
@@ -1,4 +1,5 @@
import { execSync, type ChildProcess } from 'node:child_process'; import { execSync, type ChildProcess } from 'node:child_process';
import path from 'node:path';
import process from 'node:process'; import process from 'node:process';
import { import {
@@ -80,6 +81,11 @@ export function getDefaultProfileDir(): string {
}); });
} }
export function getAccountProfileDir(alias: string): string {
const base = getDefaultProfileDir();
return path.join(path.dirname(base), `wechat-${alias}`);
}
export interface ChromeSession { export interface ChromeSession {
cdp: CdpConnection; cdp: CdpConnection;
sessionId: string; sessionId: string;
@@ -1,13 +1,8 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import os from "node:os";
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";
interface WechatConfig {
appId: string;
appSecret: string;
}
interface AccessTokenResponse { interface AccessTokenResponse {
access_token?: string; access_token?: string;
@@ -38,53 +33,14 @@ interface ArticleOptions {
thumbMediaId: string; thumbMediaId: string;
articleType: ArticleType; articleType: ArticleType;
imageMediaIds?: string[]; imageMediaIds?: string[];
needOpenComment?: number;
onlyFansCanComment?: number;
} }
const TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token"; const TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token";
const UPLOAD_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material"; const UPLOAD_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";
function loadEnvFile(envPath: string): Record<string, string> {
const env: Record<string, string> = {};
if (!fs.existsSync(envPath)) return env;
const content = fs.readFileSync(envPath, "utf-8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx > 0) {
const key = trimmed.slice(0, eqIdx).trim();
let value = trimmed.slice(eqIdx + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
env[key] = value;
}
}
return env;
}
function loadConfig(): WechatConfig {
const cwdEnvPath = path.join(process.cwd(), ".baoyu-skills", ".env");
const homeEnvPath = path.join(os.homedir(), ".baoyu-skills", ".env");
const cwdEnv = loadEnvFile(cwdEnvPath);
const homeEnv = loadEnvFile(homeEnvPath);
const appId = process.env.WECHAT_APP_ID || cwdEnv.WECHAT_APP_ID || homeEnv.WECHAT_APP_ID;
const appSecret = process.env.WECHAT_APP_SECRET || cwdEnv.WECHAT_APP_SECRET || homeEnv.WECHAT_APP_SECRET;
if (!appId || !appSecret) {
throw new Error(
"Missing WECHAT_APP_ID or WECHAT_APP_SECRET.\n" +
"Set via environment variables or in .baoyu-skills/.env file."
);
}
return { appId, appSecret };
}
async function fetchAccessToken(appId: string, appSecret: string): Promise<string> { async function fetchAccessToken(appId: string, appSecret: string): 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}`;
@@ -241,6 +197,9 @@ async function publishToDraft(
let article: Record<string, unknown>; let article: Record<string, unknown>;
const noc = options.needOpenComment ?? 1;
const ofcc = options.onlyFansCanComment ?? 0;
if (options.articleType === "newspic") { if (options.articleType === "newspic") {
if (!options.imageMediaIds || options.imageMediaIds.length === 0) { if (!options.imageMediaIds || options.imageMediaIds.length === 0) {
throw new Error("newspic requires at least one image"); throw new Error("newspic requires at least one image");
@@ -249,8 +208,8 @@ async function publishToDraft(
article_type: "newspic", article_type: "newspic",
title: options.title, title: options.title,
content: options.content, content: options.content,
need_open_comment: 1, need_open_comment: noc,
only_fans_can_comment: 0, only_fans_can_comment: ofcc,
image_info: { image_info: {
image_list: options.imageMediaIds.map(id => ({ image_media_id: id })), image_list: options.imageMediaIds.map(id => ({ image_media_id: id })),
}, },
@@ -262,8 +221,8 @@ async function publishToDraft(
title: options.title, title: options.title,
content: options.content, content: options.content,
thumb_media_id: options.thumbMediaId, thumb_media_id: options.thumbMediaId,
need_open_comment: 1, need_open_comment: noc,
only_fans_can_comment: 0, only_fans_can_comment: ofcc,
}; };
if (options.author) article.author = options.author; if (options.author) article.author = options.author;
if (options.digest) article.digest = options.digest; if (options.digest) article.digest = options.digest;
@@ -368,6 +327,7 @@ Options:
--theme <name> Theme name for markdown (default, grace, simple, modern). Default: default --theme <name> Theme name for markdown (default, grace, simple, modern). Default: default
--color <name|hex> Primary color (blue, green, vermilion, etc. or hex) --color <name|hex> Primary color (blue, green, vermilion, etc. or hex)
--cover <path> Cover image path (local or URL) --cover <path> Cover image path (local or URL)
--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
--help Show this help --help Show this help
@@ -412,6 +372,7 @@ interface CliArgs {
theme: string; theme: string;
color?: string; color?: string;
cover?: string; cover?: string;
account?: string;
citeStatus: boolean; citeStatus: boolean;
dryRun: boolean; dryRun: boolean;
} }
@@ -449,6 +410,8 @@ function parseArgs(argv: string[]): CliArgs {
args.color = argv[++i]; args.color = argv[++i];
} else if (arg === "--cover" && argv[i + 1]) { } else if (arg === "--cover" && argv[i + 1]) {
args.cover = argv[++i]; args.cover = argv[++i];
} else if (arg === "--account" && argv[i + 1]) {
args.account = argv[++i];
} else if (arg === "--cite") { } else if (arg === "--cite") {
args.citeStatus = true; args.citeStatus = true;
} else if (arg === "--no-cite") { } else if (arg === "--no-cite") {
@@ -550,6 +513,12 @@ async function main(): Promise<void> {
if (digest) console.error(`[wechat-api] Digest: ${digest.slice(0, 50)}...`); if (digest) console.error(`[wechat-api] Digest: ${digest.slice(0, 50)}...`);
console.error(`[wechat-api] Type: ${args.articleType}`); console.error(`[wechat-api] Type: ${args.articleType}`);
const extConfig = loadWechatExtendConfig();
const resolved = resolveAccount(extConfig, args.account);
if (resolved.name) console.error(`[wechat-api] Account: ${resolved.name} (${resolved.alias})`);
if (!author && resolved.default_author) author = resolved.default_author;
if (args.dryRun) { if (args.dryRun) {
console.log(JSON.stringify({ console.log(JSON.stringify({
articleType: args.articleType, articleType: args.articleType,
@@ -558,13 +527,14 @@ async function main(): Promise<void> {
digest: digest || undefined, digest: digest || undefined,
htmlPath, htmlPath,
contentLength: htmlContent.length, contentLength: htmlContent.length,
account: resolved.alias || undefined,
}, null, 2)); }, null, 2));
return; return;
} }
const config = loadConfig(); const creds = loadCredentials(resolved);
console.error("[wechat-api] Fetching access token..."); console.error("[wechat-api] Fetching access token...");
const accessToken = await fetchAccessToken(config.appId, config.appSecret); const accessToken = await fetchAccessToken(creds.appId, creds.appSecret);
console.error("[wechat-api] Uploading images..."); console.error("[wechat-api] Uploading images...");
const { html: processedHtml, firstMediaId, allMediaIds } = await uploadImagesInHtml( const { html: processedHtml, firstMediaId, allMediaIds } = await uploadImagesInHtml(
@@ -617,6 +587,8 @@ async function main(): Promise<void> {
thumbMediaId, thumbMediaId,
articleType: args.articleType, articleType: args.articleType,
imageMediaIds: args.articleType === "newspic" ? allMediaIds : undefined, imageMediaIds: args.articleType === "newspic" ? allMediaIds : undefined,
needOpenComment: resolved.need_open_comment,
onlyFansCanComment: resolved.only_fans_can_comment,
}, accessToken); }, accessToken);
console.log(JSON.stringify({ console.log(JSON.stringify({
@@ -3,7 +3,8 @@ 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, 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';
const WECHAT_URL = 'https://mp.weixin.qq.com/'; const WECHAT_URL = 'https://mp.weixin.qq.com/';
@@ -690,6 +691,7 @@ Options:
--image <path> Content image, can repeat (only with --content) --image <path> Content image, can repeat (only with --content)
--submit Save as draft --submit Save as draft
--profile <dir> Chrome profile directory --profile <dir> Chrome profile directory
--account <alias> Select account by alias (for multi-account setups)
--cdp-port <port> Connect to existing Chrome debug port instead of launching new instance --cdp-port <port> Connect to existing Chrome debug port instead of launching new instance
Examples: Examples:
@@ -725,6 +727,7 @@ async function main(): Promise<void> {
let submit = false; let submit = false;
let profileDir: string | undefined; let profileDir: string | undefined;
let cdpPort: number | undefined; let cdpPort: number | undefined;
let accountAlias: string | undefined;
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
const arg = args[i]!; const arg = args[i]!;
@@ -741,9 +744,20 @@ async function main(): Promise<void> {
else if (arg === '--image' && args[i + 1]) images.push(args[++i]!); else if (arg === '--image' && args[i + 1]) images.push(args[++i]!);
else if (arg === '--submit') submit = true; else if (arg === '--submit') submit = true;
else if (arg === '--profile' && args[i + 1]) profileDir = args[++i]; else if (arg === '--profile' && args[i + 1]) profileDir = args[++i];
else if (arg === '--account' && args[i + 1]) accountAlias = args[++i];
else if (arg === '--cdp-port' && args[i + 1]) cdpPort = parseInt(args[++i]!, 10); else if (arg === '--cdp-port' && args[i + 1]) cdpPort = parseInt(args[++i]!, 10);
} }
const extConfig = loadWechatExtendConfig();
const resolved = resolveAccount(extConfig, accountAlias);
if (resolved.name) console.log(`[wechat] Account: ${resolved.name} (${resolved.alias})`);
if (!author && resolved.default_author) author = resolved.default_author;
if (!profileDir && resolved.alias) {
profileDir = resolved.chrome_profile_path || getAccountProfileDir(resolved.alias);
}
if (!markdownFile && !htmlFile && !title) { console.error('Error: --title is required (or use --markdown/--html)'); process.exit(1); } if (!markdownFile && !htmlFile && !title) { console.error('Error: --title is required (or use --markdown/--html)'); process.exit(1); }
if (!markdownFile && !htmlFile && !content) { console.error('Error: --content, --html, or --markdown is required'); process.exit(1); } if (!markdownFile && !htmlFile && !content) { console.error('Error: --content, --html, or --markdown is required'); process.exit(1); }
@@ -7,9 +7,11 @@ import {
CdpConnection, CdpConnection,
findChromeExecutable, findChromeExecutable,
getDefaultProfileDir, getDefaultProfileDir,
getAccountProfileDir,
launchChrome, launchChrome,
sleep, sleep,
} from './cdp.ts'; } from './cdp.ts';
import { loadWechatExtendConfig, resolveAccount } from './wechat-extend-config.ts';
const WECHAT_URL = 'https://mp.weixin.qq.com/'; const WECHAT_URL = 'https://mp.weixin.qq.com/';
@@ -664,6 +666,7 @@ Options:
--image <path> Add image (can be repeated) --image <path> Add image (can be repeated)
--submit Save as draft (default: preview only) --submit Save as draft (default: preview only)
--profile <dir> Chrome profile directory --profile <dir> Chrome profile directory
--account <alias> Select account by alias (for multi-account setups)
--help Show this help --help Show this help
Examples: Examples:
@@ -685,6 +688,7 @@ async function main(): Promise<void> {
let content: string | undefined; let content: string | undefined;
let markdownFile: string | undefined; let markdownFile: string | undefined;
let imagesDir: string | undefined; let imagesDir: string | undefined;
let accountAlias: string | undefined;
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
const arg = args[i]!; const arg = args[i]!;
@@ -702,9 +706,19 @@ async function main(): Promise<void> {
submit = true; submit = true;
} else if (arg === '--profile' && args[i + 1]) { } else if (arg === '--profile' && args[i + 1]) {
profileDir = args[++i]; profileDir = args[++i];
} else if (arg === '--account' && args[i + 1]) {
accountAlias = args[++i];
} }
} }
const extConfig = loadWechatExtendConfig();
const resolved = resolveAccount(extConfig, accountAlias);
if (resolved.name) console.log(`[wechat-browser] Account: ${resolved.name} (${resolved.alias})`);
if (!profileDir && resolved.alias) {
profileDir = resolved.chrome_profile_path || getAccountProfileDir(resolved.alias);
}
if (!markdownFile && !title) { if (!markdownFile && !title) {
console.error('Error: --title or --markdown is required'); console.error('Error: --title or --markdown is required');
process.exit(1); process.exit(1);
@@ -0,0 +1,245 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
export interface WechatAccount {
name: string;
alias: string;
default?: boolean;
default_publish_method?: string;
default_author?: string;
need_open_comment?: number;
only_fans_can_comment?: number;
app_id?: string;
app_secret?: string;
chrome_profile_path?: string;
}
export interface WechatExtendConfig {
default_theme?: string;
default_color?: string;
default_publish_method?: string;
default_author?: string;
need_open_comment?: number;
only_fans_can_comment?: number;
chrome_profile_path?: string;
accounts?: WechatAccount[];
}
export interface ResolvedAccount {
name?: string;
alias?: string;
default_publish_method?: string;
default_author?: string;
need_open_comment: number;
only_fans_can_comment: number;
app_id?: string;
app_secret?: string;
chrome_profile_path?: string;
}
function stripQuotes(s: string): string {
return s.replace(/^['"]|['"]$/g, "");
}
function toBool01(v: string): number {
return v === "1" || v === "true" ? 1 : 0;
}
function parseWechatExtend(content: string): WechatExtendConfig {
const config: WechatExtendConfig = {};
const lines = content.split("\n");
let inAccounts = false;
let current: Record<string, string> | null = null;
const rawAccounts: Record<string, string>[] = [];
for (const raw of lines) {
const trimmed = raw.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
if (trimmed === "accounts:") {
inAccounts = true;
continue;
}
if (inAccounts) {
const listMatch = raw.match(/^\s+-\s+(.+)$/);
if (listMatch) {
if (current) rawAccounts.push(current);
current = {};
const kv = listMatch[1]!;
const ci = kv.indexOf(":");
if (ci > 0) {
current[kv.slice(0, ci).trim()] = stripQuotes(kv.slice(ci + 1).trim());
}
continue;
}
if (current && /^\s{2,}/.test(raw) && !trimmed.startsWith("-")) {
const ci = trimmed.indexOf(":");
if (ci > 0) {
current[trimmed.slice(0, ci).trim()] = stripQuotes(trimmed.slice(ci + 1).trim());
}
continue;
}
if (!/^\s/.test(raw)) {
if (current) rawAccounts.push(current);
current = null;
inAccounts = false;
} else {
continue;
}
}
const ci = trimmed.indexOf(":");
if (ci < 0) continue;
const key = trimmed.slice(0, ci).trim();
const val = stripQuotes(trimmed.slice(ci + 1).trim());
if (val === "null" || val === "") continue;
switch (key) {
case "default_theme": config.default_theme = val; break;
case "default_color": config.default_color = val; break;
case "default_publish_method": config.default_publish_method = val; break;
case "default_author": config.default_author = 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 "chrome_profile_path": config.chrome_profile_path = val; break;
}
}
if (current) rawAccounts.push(current);
if (rawAccounts.length > 0) {
config.accounts = rawAccounts.map(a => ({
name: a.name || "",
alias: a.alias || "",
default: a.default === "true" || a.default === "1",
default_publish_method: a.default_publish_method || undefined,
default_author: a.default_author || undefined,
need_open_comment: a.need_open_comment ? toBool01(a.need_open_comment) : undefined,
only_fans_can_comment: a.only_fans_can_comment ? toBool01(a.only_fans_can_comment) : undefined,
app_id: a.app_id || undefined,
app_secret: a.app_secret || undefined,
chrome_profile_path: a.chrome_profile_path || undefined,
}));
}
return config;
}
export function loadWechatExtendConfig(): WechatExtendConfig {
const paths = [
path.join(process.cwd(), ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"),
path.join(
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"),
"baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"
),
path.join(os.homedir(), ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"),
];
for (const p of paths) {
try {
const content = fs.readFileSync(p, "utf-8");
return parseWechatExtend(content);
} catch {
continue;
}
}
return {};
}
function selectAccount(config: WechatExtendConfig, alias?: string): WechatAccount | undefined {
if (!config.accounts || config.accounts.length === 0) return undefined;
if (alias) return config.accounts.find(a => a.alias === alias);
if (config.accounts.length === 1) return config.accounts[0];
return config.accounts.find(a => a.default);
}
export function resolveAccount(config: WechatExtendConfig, alias?: string): ResolvedAccount {
const acct = selectAccount(config, alias);
return {
name: acct?.name,
alias: acct?.alias,
default_publish_method: acct?.default_publish_method ?? config.default_publish_method,
default_author: acct?.default_author ?? config.default_author,
need_open_comment: acct?.need_open_comment ?? config.need_open_comment ?? 1,
only_fans_can_comment: acct?.only_fans_can_comment ?? config.only_fans_can_comment ?? 0,
app_id: acct?.app_id,
app_secret: acct?.app_secret,
chrome_profile_path: acct?.chrome_profile_path ?? config.chrome_profile_path,
};
}
function loadEnvFile(envPath: string): Record<string, string> {
const env: Record<string, string> = {};
if (!fs.existsSync(envPath)) return env;
const content = fs.readFileSync(envPath, "utf-8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx > 0) {
const key = trimmed.slice(0, eqIdx).trim();
let value = trimmed.slice(eqIdx + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
env[key] = value;
}
}
return env;
}
function aliasToEnvKey(alias: string): string {
return alias.toUpperCase().replace(/-/g, "_");
}
export function loadCredentials(account?: ResolvedAccount): { appId: string; appSecret: string } {
if (account?.app_id && account?.app_secret) {
return { appId: account.app_id, appSecret: account.app_secret };
}
const cwdEnvPath = path.join(process.cwd(), ".baoyu-skills", ".env");
const homeEnvPath = path.join(os.homedir(), ".baoyu-skills", ".env");
const cwdEnv = loadEnvFile(cwdEnvPath);
const homeEnv = loadEnvFile(homeEnvPath);
const prefix = account?.alias ? `WECHAT_${aliasToEnvKey(account.alias)}_` : "";
let appId = "";
let appSecret = "";
if (prefix) {
appId = process.env[`${prefix}APP_ID`]
|| cwdEnv[`${prefix}APP_ID`]
|| homeEnv[`${prefix}APP_ID`]
|| "";
appSecret = process.env[`${prefix}APP_SECRET`]
|| cwdEnv[`${prefix}APP_SECRET`]
|| homeEnv[`${prefix}APP_SECRET`]
|| "";
}
if (!appId) {
appId = process.env.WECHAT_APP_ID || cwdEnv.WECHAT_APP_ID || homeEnv.WECHAT_APP_ID || "";
}
if (!appSecret) {
appSecret = process.env.WECHAT_APP_SECRET || cwdEnv.WECHAT_APP_SECRET || homeEnv.WECHAT_APP_SECRET || "";
}
if (!appId || !appSecret) {
const hint = account?.alias ? ` (account: ${account.alias})` : "";
throw new Error(
`Missing WECHAT_APP_ID or WECHAT_APP_SECRET${hint}.\n` +
"Set via EXTEND.md account config, environment variables, or .baoyu-skills/.env file."
);
}
return { appId, appSecret };
}
export function listAccounts(config: WechatExtendConfig): string[] {
return (config.accounts || []).map(a => a.alias);
}