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.
This commit is contained in:
Jim Liu 宝玉 2026-03-11 23:24:02 -05:00
parent c0941f8089
commit 5276fae6bd
9 changed files with 531 additions and 55 deletions

View File

@ -581,6 +581,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: wx1234567890abcdef
app_secret: your_app_secret_here
- 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.

View File

@ -581,6 +581,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: wx1234567890abcdef
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 绕过反自动化检测。

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: wx1234567890abcdef
app_secret: abc123secret456
- 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=wx1234567890abcdef
WECHAT_BAOYU_APP_SECRET=abc123secret456
# Account: ai-tools
WECHAT_AI_TOOLS_APP_ID=wxabcdef1234567890
WECHAT_AI_TOOLS_APP_SECRET=def789secret012
```
### 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

View File

@ -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.

View File

@ -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;

View File

@ -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({

View File

@ -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); }

View File

@ -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);

View File

@ -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);
}