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:
parent
c0941f8089
commit
5276fae6bd
41
README.md
41
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
41
README.zh.md
41
README.zh.md
|
|
@ -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 绕过反自动化检测。
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue