@lyhue1991/wxgzh 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,7 +40,7 @@ npm install -g @lyhue1991/wxgzh
40
40
  * step2: 一键配置 (提前准备好微信公众号AppID和 AppSecret , 并配置好IP白名单)
41
41
 
42
42
  ```bash
43
- wxgzh config --appid 你的AppID --appsecret 你的AppSecret
43
+ wxgzh config --account 公众号名称1 --appid 你的AppID --appsecret 你的AppSecret
44
44
 
45
45
  ```
46
46
 
@@ -67,24 +67,93 @@ wxgzh article.md --author 文章作者姓名
67
67
  前往微信公众号公众平台,使用已认证的公众号管理员账号登录:
68
68
 
69
69
  1. 打开公众号后台
70
- 2. 进入“设置与开发”
71
- 3. 找到“开发接口管理”
70
+ 2. 进入"设置与开发"
71
+ 3. 找到"开发接口管理"
72
72
  4. 查看并复制 `AppID` 和 `AppSecret`
73
73
 
74
- 拿到后,执行:
74
+ 拿到后,执行(**必须同时指定公众号账号名**):
75
75
 
76
76
  ```bash
77
- wxgzh config --appid 你的AppID --appsecret 你的AppSecret
77
+ wxgzh config --account 公众号名称1 --appid 你的AppID --appsecret 你的AppSecret
78
78
  ```
79
79
 
80
80
  也可以同时设置默认作者、默认主题:
81
81
 
82
82
  ```bash
83
- wxgzh config --appid 你的AppID --appsecret 你的AppSecret --author "wxgzh" --default-theme blue
83
+ wxgzh config --account 公众号名称1 --appid 你的AppID --appsecret 你的AppSecret --author "wxgzh" --default-theme blue
84
84
  ```
85
85
 
86
+ ### 2. 如何管理多个公众号账号
86
87
 
87
- ### 2. 如何把本机IP加入公众号 IP 白名单
88
+ `wxgzh` 支持同时管理多个公众号账号,适合有多个公众号或需要在不同账号间切换的场景。
89
+
90
+ **添加多个账号:**
91
+
92
+ ```bash
93
+ # 主账号
94
+ wxgzh config --account 公众号名称1 --appid 你的AppID --appsecret 你的AppSecret
95
+
96
+ # 备用账号
97
+ wxgzh config --account 公众号名称2 --appid 另一个AppID --appsecret 另一个AppSecret
98
+ ```
99
+
100
+ **设置默认账号:**
101
+
102
+ ```bash
103
+ wxgzh config --set-default-account 公众号名称1
104
+ ```
105
+
106
+ **查看已配置的账号:**
107
+
108
+ ```bash
109
+ wxgzh config --list-accounts
110
+ ```
111
+
112
+ **发布时指定账号:**
113
+
114
+ ```bash
115
+ # 使用公众号名称1发布
116
+ wxgzh article.md --account 公众号名称1
117
+
118
+ # 使用公众号名称2发布
119
+ wxgzh article.md --account 公众号名称2
120
+
121
+ # 不指定则使用默认账号
122
+ wxgzh article.md
123
+ ```
124
+
125
+ **账号选择优先级:**
126
+
127
+ 命令行 `--account` > front matter 中的 `account` 字段 > 环境变量 `WX_ACCOUNT` > 用户配置的默认账号
128
+
129
+ **使用 front matter 指定账号:**
130
+
131
+ ```yaml
132
+ ---
133
+ title: 我的文章
134
+ author: wxgzh
135
+ account: 公众号名称2
136
+ ---
137
+
138
+ # 文章正文
139
+ ```
140
+
141
+ **使用环境变量(适用于 CI/CD 场景):**
142
+
143
+ ```bash
144
+ export WX_ACCOUNT=公众号名称2
145
+ export WX_APPID=你的AppID
146
+ export WX_APPSECRET=你的AppSecret
147
+ wxgzh article.md
148
+ ```
149
+
150
+ **删除账号配置:**
151
+
152
+ ```bash
153
+ wxgzh config --remove-account 公众号名称2
154
+ ```
155
+
156
+ ### 3. 如何把本机IP加入公众号 IP 白名单
88
157
 
89
158
  > [!IMPORTANT]
90
159
  > 调用微信公众号接口前,除了配置 `appid` 和 `appsecret`,还要把当前机器的公网 IP 加到公众号后台的 IP 白名单。
@@ -100,7 +169,7 @@ wxgzh config --appid 你的AppID --appsecret 你的AppSecret --author "wxgzh" --
100
169
  查看本机公网 IP 的方法:浏览器访问 https://ip.sb/ 查看 Address
101
170
 
102
171
 
103
- ### 3. 如何查看当前配置
172
+ ### 4. 如何查看当前配置
104
173
 
105
174
  ```bash
106
175
  wxgzh config --list
@@ -391,6 +460,7 @@ enableComment: true
391
460
  - `author`:作者
392
461
  - `digest`:摘要
393
462
  - `theme`:主题名
463
+ - `account`:目标公众号账号名
394
464
  - `cover`:封面图路径
395
465
  - `enableComment`:是否开启评论
396
466
 
@@ -415,6 +485,7 @@ enableComment: true
415
485
 
416
486
  ```bash
417
487
  wxgzh --help
488
+ wxgzh --version
418
489
  wxgzh article.md
419
490
  wxgzh config --list
420
491
  wxgzh config --list-themes
@@ -423,5 +494,3 @@ wxgzh fix .wxgzh/article.html --no-upload
423
494
  wxgzh cover --title "我的文章" --to .wxgzh/cover.jpg
424
495
  wxgzh publish --article .wxgzh/article.html --cover .wxgzh/cover.jpg
425
496
  ```
426
-
427
-
@@ -6,57 +6,109 @@ const config_1 = require("../utils/config");
6
6
  const logger_1 = require("../utils/logger");
7
7
  function listConfig(config) {
8
8
  const payload = {
9
- ...config,
10
- appsecret: (0, config_1.maskSecret)(config.appsecret)
9
+ account: config.account,
10
+ currentAccount: config.currentAccount,
11
+ availableAccounts: Object.keys(config.accounts),
12
+ appid: config.appid,
13
+ appsecret: (0, config_1.maskSecret)(config.appsecret),
14
+ author: config.author,
15
+ defaultTheme: config.defaultTheme,
16
+ enableComment: config.enableComment
11
17
  };
12
18
  console.log(JSON.stringify(payload, null, 2));
13
19
  (0, logger_1.info)(`用户级配置路径: ${(0, config_1.getUserConfigPath)()}`);
14
20
  }
21
+ function listAccounts(config) {
22
+ const accountNames = Object.keys(config.accounts);
23
+ if (accountNames.length === 0) {
24
+ (0, logger_1.info)('当前还没有配置任何公众号账号');
25
+ return;
26
+ }
27
+ for (const accountName of accountNames) {
28
+ console.log(accountName === config.currentAccount ? `${accountName} (default)` : accountName);
29
+ }
30
+ (0, logger_1.info)(`用户级配置路径: ${(0, config_1.getUserConfigPath)()}`);
31
+ }
15
32
  function registerConfigCommand(program) {
16
33
  program
17
34
  .command('config')
18
35
  .description('查看或更新 wxgzh 的用户级默认配置')
19
36
  .usage('[options]')
20
37
  .helpOption('-h, --help', '查看帮助')
21
- .option('--appid <appid>', '设置微信公众号 appid')
22
- .option('--appsecret <appsecret>', '设置微信公众号 appsecret')
23
- .option('--author <author>', '设置默认作者名')
24
- .option('--default-theme <theme>', `设置默认主题,可用值:${(0, themes_1.listAvailableThemes)().join(', ')}`)
25
- .option('--enable-comment', '把默认评论开关设为开启')
26
- .option('--disable-comment', '把默认评论开关设为关闭')
38
+ .option('--account <name>', '指定要查看或更新的公众号账号名;设置 appid/appsecret 时必填')
39
+ .option('--appid <appid>', '为目标账号设置微信公众号 appid')
40
+ .option('--appsecret <appsecret>', '为目标账号设置微信公众号 appsecret')
41
+ .option('--author <author>', '为目标账号设置默认作者名')
42
+ .option('--default-theme <theme>', `为目标账号设置默认主题,可用值:${(0, themes_1.listAvailableThemes)().join(', ')}`)
43
+ .option('--enable-comment', '把目标账号的默认评论开关设为开启')
44
+ .option('--disable-comment', '把目标账号的默认评论开关设为关闭')
45
+ .option('--set-default-account <name>', '设置默认公众号账号')
46
+ .option('--remove-account <name>', '删除指定公众号账号配置')
27
47
  .option('--list', '查看当前生效配置')
48
+ .option('--list-accounts', '查看全部已配置账号')
28
49
  .option('--list-themes', '查看全部可用主题名')
29
50
  .option('--clear', '清空用户级配置文件')
30
- .addHelpText('after', '\n示例:\n $ wxgzh config --list\n $ wxgzh config --list-themes\n $ wxgzh config --author "wxgzh" --default-theme blue\n $ wxgzh config --enable-comment\n')
51
+ .addHelpText('after', '\n示例:\n $ wxgzh config --list\n $ wxgzh config --list-accounts\n $ wxgzh config --account 公众号名称1 --appid wx123 --appsecret abc123\n $ wxgzh config --account 公众号名称1 --author "wxgzh" --default-theme blue\n $ wxgzh config --set-default-account 公众号名称1\n\n说明:\n 设置 appid 或 appsecret 时,必须同时通过 --account 指定公众号账号名\n')
31
52
  .action(async (options, command) => {
32
53
  const mergedOptions = command.optsWithGlobals();
54
+ const patch = {
55
+ ...(mergedOptions.appid ? { appid: mergedOptions.appid } : {}),
56
+ ...(mergedOptions.appsecret ? { appsecret: mergedOptions.appsecret } : {}),
57
+ ...(mergedOptions.author ? { author: mergedOptions.author } : {}),
58
+ ...(mergedOptions.defaultTheme ? { defaultTheme: (0, themes_1.assertThemeExists)(mergedOptions.defaultTheme) } : {}),
59
+ ...(mergedOptions.enableComment ? { enableComment: true } : {}),
60
+ ...(mergedOptions.disableComment ? { enableComment: false } : {})
61
+ };
33
62
  if (mergedOptions.clear) {
34
63
  await (0, config_1.clearUserConfig)();
35
64
  (0, logger_1.success)('已清空用户级配置');
36
65
  return;
37
66
  }
38
67
  if (mergedOptions.list) {
39
- listConfig(await (0, config_1.loadConfig)());
68
+ listConfig(await (0, config_1.loadConfig)({ account: mergedOptions.account }));
40
69
  return;
41
70
  }
42
71
  if (mergedOptions.listThemes) {
43
72
  console.log((0, themes_1.listAvailableThemes)().join('\n'));
44
73
  return;
45
74
  }
46
- const patch = {
47
- ...(mergedOptions.appid ? { appid: mergedOptions.appid } : {}),
48
- ...(mergedOptions.appsecret ? { appsecret: mergedOptions.appsecret } : {}),
49
- ...(mergedOptions.author ? { author: mergedOptions.author } : {}),
50
- ...(mergedOptions.defaultTheme ? { defaultTheme: (0, themes_1.assertThemeExists)(mergedOptions.defaultTheme) } : {}),
51
- ...(mergedOptions.enableComment ? { enableComment: true } : {}),
52
- ...(mergedOptions.disableComment ? { enableComment: false } : {})
53
- };
54
- if (Object.keys(patch).length === 0) {
55
- listConfig(await (0, config_1.loadConfig)());
75
+ if (mergedOptions.listAccounts) {
76
+ listAccounts(await (0, config_1.loadUserConfig)());
77
+ return;
78
+ }
79
+ if (mergedOptions.removeAccount) {
80
+ if (Object.keys(patch).length > 0 || mergedOptions.setDefaultAccount) {
81
+ throw new Error('--remove-account 不能与其他写入类参数同时使用');
82
+ }
83
+ const next = await (0, config_1.removeAccountConfig)(mergedOptions.removeAccount);
84
+ (0, logger_1.success)(`已删除公众号账号 ${mergedOptions.removeAccount}`);
85
+ listAccounts(next);
86
+ return;
87
+ }
88
+ let selectedAccount = mergedOptions.account;
89
+ const hasCredentialsToSave = mergedOptions.appid || mergedOptions.appsecret;
90
+ if (hasCredentialsToSave && !selectedAccount) {
91
+ throw new Error('配置 appid 或 appsecret 时,必须通过 --account <name> 指定公众号账号名');
92
+ }
93
+ if (Object.keys(patch).length > 0) {
94
+ if (!selectedAccount) {
95
+ const userConfig = await (0, config_1.loadUserConfig)();
96
+ selectedAccount = userConfig.currentAccount;
97
+ }
98
+ if (!selectedAccount) {
99
+ throw new Error('当前没有默认公众号账号,请通过 --account <name> 指定要写入的账号');
100
+ }
101
+ await (0, config_1.saveAccountConfig)(selectedAccount, patch);
102
+ }
103
+ if (mergedOptions.setDefaultAccount) {
104
+ await (0, config_1.setDefaultAccount)(mergedOptions.setDefaultAccount);
105
+ selectedAccount = mergedOptions.setDefaultAccount;
106
+ }
107
+ if (Object.keys(patch).length === 0 && !mergedOptions.setDefaultAccount) {
108
+ listConfig(await (0, config_1.loadConfig)({ account: mergedOptions.account }));
56
109
  return;
57
110
  }
58
- const next = await (0, config_1.saveUserConfig)(patch);
59
111
  (0, logger_1.success)('用户级配置已更新');
60
- console.log(JSON.stringify({ ...next, appsecret: (0, config_1.maskSecret)(next.appsecret) }, null, 2));
112
+ listConfig(await (0, config_1.loadConfig)({ account: selectedAccount }));
61
113
  });
62
114
  }
package/dist/cli/fix.js CHANGED
@@ -5,9 +5,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.registerFixCommand = registerFixCommand;
7
7
  const node_path_1 = __importDefault(require("node:path"));
8
+ const converter_1 = require("../core/converter");
8
9
  const fixer_1 = require("../core/fixer");
9
10
  const wechat_1 = require("../core/wechat");
10
11
  const config_1 = require("../utils/config");
12
+ const fs_1 = require("../utils/fs");
11
13
  const logger_1 = require("../utils/logger");
12
14
  function registerFixCommand(program) {
13
15
  program
@@ -16,19 +18,20 @@ function registerFixCommand(program) {
16
18
  .usage('<article.html> [options]')
17
19
  .helpOption('-h, --help', '查看帮助')
18
20
  .argument('<article.html>', '要修复的 HTML 文件路径')
21
+ .option('--account <name>', '指定上传正文图片时使用的公众号账号')
19
22
  .option('--no-upload', '只修复 HTML 结构,不上传图片到微信')
20
23
  .option('--cdn <url>', '把本地图片路径替换为指定 CDN 地址')
21
- .addHelpText('after', '\n示例:\n $ wxgzh fix .wxgzh/article.html\n $ wxgzh fix .wxgzh/article.html --no-upload\n $ wxgzh fix .wxgzh/article.html --cdn https://cdn.example.com\n')
24
+ .addHelpText('after', '\n示例:\n $ wxgzh fix .wxgzh/article.html\n $ wxgzh fix .wxgzh/article.html --account 公众号名称1\n $ wxgzh fix .wxgzh/article.html --no-upload\n $ wxgzh fix .wxgzh/article.html --cdn https://cdn.example.com\n')
22
25
  .action(async (articlePath, options) => {
26
+ const absoluteArticlePath = node_path_1.default.resolve(articlePath);
23
27
  let wechat;
24
28
  if (options.upload !== false) {
25
- const config = await (0, config_1.loadConfig)();
26
- if (!config.appid || !config.appsecret) {
27
- (0, logger_1.error)('未配置微信认证信息,请先运行: wxgzh config --appid xxx --appsecret yyy');
28
- }
29
- wechat = new wechat_1.WechatClient({ appid: config.appid, appsecret: config.appsecret });
29
+ const html = await (0, fs_1.readTextFile)(absoluteArticlePath);
30
+ const metadata = (0, converter_1.readHtmlMetadata)(html);
31
+ const config = await (0, config_1.loadConfig)({ account: options.account ?? metadata.account });
32
+ wechat = new wechat_1.WechatClient((0, config_1.resolveWechatCredentials)(config));
30
33
  }
31
- const result = await (0, fixer_1.fixHtmlFile)(node_path_1.default.resolve(articlePath), {
34
+ const result = await (0, fixer_1.fixHtmlFile)(absoluteArticlePath, {
32
35
  upload: options.upload !== false,
33
36
  cdn: options.cdn,
34
37
  wechat
package/dist/cli/index.js CHANGED
@@ -36,6 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
+ const node_fs_1 = require("node:fs");
39
40
  const promises_1 = require("node:fs/promises");
40
41
  const node_path_1 = __importDefault(require("node:path"));
41
42
  const commander_1 = require("commander");
@@ -55,6 +56,7 @@ dotenv_1.default.config();
55
56
  const EXAMPLES_TEXT = [
56
57
  '常用示例:',
57
58
  ' $ wxgzh article.md',
59
+ ' $ wxgzh article.md --account 公众号名称1',
58
60
  ' $ wxgzh article.md --theme blue',
59
61
  ' $ wxgzh article.md --author "wxgzh" --digest "这是一篇摘要"',
60
62
  ' $ wxgzh article.md --cover ./cover.jpg',
@@ -65,6 +67,14 @@ const EXAMPLES_TEXT = [
65
67
  ' $ wxgzh cover --title "我的文章" --to .wxgzh/cover.jpg',
66
68
  ' $ wxgzh publish --article .wxgzh/article.html --cover .wxgzh/cover.jpg'
67
69
  ].join('\n');
70
+ function getCliVersion() {
71
+ const packageJsonPath = node_path_1.default.resolve(__dirname, '../../package.json');
72
+ const packageJson = JSON.parse((0, node_fs_1.readFileSync)(packageJsonPath, 'utf8'));
73
+ if (typeof packageJson.version !== 'string' || packageJson.version.trim() === '') {
74
+ throw new Error('读取版本号失败: package.json 中缺少有效的 version 字段');
75
+ }
76
+ return packageJson.version;
77
+ }
68
78
  function resolveCoverValue(value) {
69
79
  return typeof value === 'string' ? value : undefined;
70
80
  }
@@ -74,25 +84,23 @@ async function runDefaultPipeline(articlePath, options) {
74
84
  const articleBaseName = node_path_1.default.basename(absoluteArticle, node_path_1.default.extname(absoluteArticle));
75
85
  const htmlPath = node_path_1.default.join(outputDir, `${articleBaseName}.html`);
76
86
  const coverPath = node_path_1.default.join(outputDir, `${articleBaseName}.cover.jpg`);
77
- const config = await (0, config_1.loadConfig)();
78
87
  const rawMarkdown = await (0, promises_1.readFile)(absoluteArticle, 'utf8');
79
88
  const parsed = (0, parser_1.parseMarkdown)(rawMarkdown);
89
+ const config = await (0, config_1.loadConfig)({ account: options.account ?? parsed.metadata.account });
80
90
  const title = parsed.metadata.title ?? articleBaseName;
81
91
  const author = options.author ?? parsed.metadata.author ?? config.author ?? 'wxgzh';
82
92
  const digest = options.digest ?? parsed.metadata.digest ?? '由 wxgzh 自动生成的公众号草稿';
83
93
  const autoCoverDisabled = options.cover === false;
84
94
  const resolvedTheme = options.theme ?? parsed.metadata.theme ?? config.defaultTheme;
85
95
  await (0, md2html_1.convertMarkdownFile)(absoluteArticle, htmlPath, {
96
+ account: config.account,
86
97
  theme: resolvedTheme ? (0, themes_1.assertThemeExists)(resolvedTheme) : undefined,
87
98
  author
88
99
  });
89
100
  (0, logger_1.info)(`已生成 HTML: ${htmlPath}`);
90
101
  const { fixHtmlFile } = await Promise.resolve().then(() => __importStar(require('../core/fixer')));
91
102
  const { WechatClient } = await Promise.resolve().then(() => __importStar(require('../core/wechat')));
92
- if (!config.appid || !config.appsecret) {
93
- throw new Error('未配置微信认证信息,请先运行: wxgzh config --appid xxx --appsecret yyy');
94
- }
95
- const wechat = new WechatClient({ appid: config.appid, appsecret: config.appsecret });
103
+ const wechat = new WechatClient((0, config_1.resolveWechatCredentials)(config));
96
104
  await fixHtmlFile(htmlPath, { upload: true, wechat });
97
105
  (0, logger_1.info)(`已修复并上传正文图片: ${htmlPath}`);
98
106
  let finalCoverPath = resolveCoverValue(options.cover) ?? parsed.metadata.cover;
@@ -109,6 +117,7 @@ async function runDefaultPipeline(articlePath, options) {
109
117
  throw new Error('当前未提供封面图。请通过 --cover 指定,或去掉 --no-cover 以自动生成简易封面。');
110
118
  }
111
119
  const result = await (0, publish_1.publishDraft)(htmlPath, finalCoverPath, {
120
+ account: config.account,
112
121
  title,
113
122
  author,
114
123
  digest,
@@ -123,10 +132,12 @@ async function main() {
123
132
  .description('把 Markdown 文章处理成微信公众号草稿,支持主题、图片修复、封面生成与一键发布')
124
133
  .usage('[article.md] [options]')
125
134
  .helpOption('-h, --help', '查看帮助')
135
+ .version(getCliVersion(), '-V, --version', '查看版本')
126
136
  .showHelpAfterError('(使用 --help 查看完整帮助)')
127
137
  .argument('[article]', 'Markdown 文件路径;传入后会自动执行 md2html -> fix -> cover -> publish')
128
138
  .option('--theme <theme>', `指定主题名,可用值:${(0, themes_1.listAvailableThemes)().join(', ')}`)
129
139
  .option('--author <author>', '覆盖作者名;未传时优先使用 front matter 或 config 中的 author')
140
+ .option('--account <name>', '指定本次发布使用的公众号账号')
130
141
  .option('--cover <cover.jpg>', '指定现成封面图;未传时默认自动生成')
131
142
  .option('--no-cover', '禁用自动封面生成;适合 front matter 已配置 cover 的场景')
132
143
  .option('--digest <digest>', '覆盖文章摘要;未传时自动从正文提取')
@@ -14,13 +14,14 @@ const config_1 = require("../utils/config");
14
14
  const fs_1 = require("../utils/fs");
15
15
  const logger_1 = require("../utils/logger");
16
16
  async function convertMarkdownFile(inputPath, outputPath, options) {
17
- const config = await (0, config_1.loadConfig)();
18
17
  const absoluteInputPath = node_path_1.default.resolve(inputPath);
19
18
  const raw = await (0, promises_1.readFile)(absoluteInputPath, 'utf8');
20
19
  const parsed = (0, parser_1.parseMarkdown)(raw);
20
+ const config = await (0, config_1.loadConfig)({ account: options?.account ?? parsed.metadata.account });
21
21
  const resolvedTheme = options?.theme ?? parsed.metadata.theme ?? config.defaultTheme ?? 'default';
22
22
  const html = await (0, converter_1.renderMarkdownToHtml)(parsed.body, {
23
23
  ...parsed.metadata,
24
+ account: options?.account ?? parsed.metadata.account ?? config.account,
24
25
  theme: (0, themes_1.assertThemeExists)(resolvedTheme),
25
26
  author: options?.author ?? parsed.metadata.author ?? config.author,
26
27
  sourceDir: node_path_1.default.dirname(absoluteInputPath)
@@ -12,14 +12,12 @@ const config_1 = require("../utils/config");
12
12
  const fs_1 = require("../utils/fs");
13
13
  const logger_1 = require("../utils/logger");
14
14
  async function publishDraft(articlePath, coverPath, options) {
15
- const config = await (0, config_1.loadConfig)();
16
- if (!config.appid || !config.appsecret) {
17
- (0, logger_1.error)('未配置微信认证信息,请先运行: wxgzh config --appid xxx --appsecret yyy');
18
- }
19
15
  const html = await (0, fs_1.readTextFile)(node_path_1.default.resolve(articlePath));
20
16
  const metadata = (0, converter_1.readHtmlMetadata)(html);
17
+ const config = await (0, config_1.loadConfig)({ account: options?.account ?? metadata.account });
18
+ const credentials = (0, config_1.resolveWechatCredentials)(config);
21
19
  const publishableContent = (0, converter_1.extractPublishableContent)(html);
22
- const client = new wechat_1.WechatClient({ appid: config.appid, appsecret: config.appsecret });
20
+ const client = new wechat_1.WechatClient(credentials);
23
21
  const cover = await client.uploadCoverImage(node_path_1.default.resolve(coverPath));
24
22
  return client.createDraft({
25
23
  title: options?.title ?? metadata.title ?? node_path_1.default.basename(articlePath, node_path_1.default.extname(articlePath)),
@@ -38,13 +36,15 @@ function registerPublishCommand(program) {
38
36
  .helpOption('-h, --help', '查看帮助')
39
37
  .requiredOption('--article <article.html>', '已处理好的 HTML 正文路径')
40
38
  .requiredOption('--cover <cover.jpg>', '封面图路径')
39
+ .option('--account <name>', '指定本次发布使用的公众号账号')
41
40
  .option('--title <title>', '覆盖文章标题')
42
41
  .option('--author <author>', '覆盖作者名')
43
42
  .option('--digest <digest>', '覆盖文章摘要')
44
43
  .option('--enable-comment', '为这篇草稿开启评论')
45
- .addHelpText('after', '\n示例:\n $ wxgzh publish --article .wxgzh/article.html --cover .wxgzh/cover.jpg\n $ wxgzh publish --article .wxgzh/article.html --cover .wxgzh/cover.jpg --title "新的标题"\n')
44
+ .addHelpText('after', '\n示例:\n $ wxgzh publish --article .wxgzh/article.html --cover .wxgzh/cover.jpg\n $ wxgzh publish --article .wxgzh/article.html --cover .wxgzh/cover.jpg --account 公众号名称1\n $ wxgzh publish --article .wxgzh/article.html --cover .wxgzh/cover.jpg --title "新的标题"\n')
46
45
  .action(async (options) => {
47
46
  const result = await publishDraft(options.article, options.cover, {
47
+ account: options.account,
48
48
  title: options.title,
49
49
  author: options.author,
50
50
  digest: options.digest,
@@ -7,12 +7,17 @@ exports.listCoverPresets = listCoverPresets;
7
7
  exports.createCover = createCover;
8
8
  const node_fs_1 = require("node:fs");
9
9
  const node_path_1 = __importDefault(require("node:path"));
10
+ const opentype_js_1 = require("opentype.js");
10
11
  const sharp_1 = __importDefault(require("sharp"));
11
12
  const BUILTIN_BACKGROUND_DIR = node_path_1.default.resolve(__dirname, '../../assets/backgrounds');
13
+ const COVER_TITLE_FONT_PATH = node_path_1.default.resolve(__dirname, '../../assets/fonts/仓耳小丸子.ttf');
12
14
  const FALLBACK_GRADIENT = {
13
15
  start: '#e8f3ef',
14
16
  end: '#c9ded6'
15
17
  };
18
+ const DEFAULT_TITLE_LINE_LENGTH = 15;
19
+ const COVER_TEXT_COLOR = '#000000b8';
20
+ let cachedCoverFont;
16
21
  function escapeXml(value) {
17
22
  return value
18
23
  .replace(/&/g, '&amp;')
@@ -21,8 +26,16 @@ function escapeXml(value) {
21
26
  .replace(/"/g, '&quot;')
22
27
  .replace(/'/g, '&apos;');
23
28
  }
24
- function escapeAttribute(value) {
25
- return escapeXml(value);
29
+ function getCoverFont() {
30
+ if (!(0, node_fs_1.existsSync)(COVER_TITLE_FONT_PATH)) {
31
+ return undefined;
32
+ }
33
+ if (!cachedCoverFont) {
34
+ const fontBuffer = (0, node_fs_1.readFileSync)(COVER_TITLE_FONT_PATH);
35
+ const arrayBuffer = fontBuffer.buffer.slice(fontBuffer.byteOffset, fontBuffer.byteOffset + fontBuffer.byteLength);
36
+ cachedCoverFont = (0, opentype_js_1.parse)(arrayBuffer);
37
+ }
38
+ return cachedCoverFont;
26
39
  }
27
40
  function readFiles(dirPath, pattern) {
28
41
  try {
@@ -79,29 +92,66 @@ function buildGradientBackground(width, height) {
79
92
  </svg>`;
80
93
  return Buffer.from(svg);
81
94
  }
95
+ function getFontLineMetrics(font, fontSize) {
96
+ const unitsPerEm = font.unitsPerEm || 1000;
97
+ const ascender = Math.round(font.ascender / unitsPerEm * fontSize);
98
+ const descender = Math.abs(Math.round(font.descender / unitsPerEm * fontSize));
99
+ return {
100
+ ascender,
101
+ lineHeight: Math.max(Math.round((ascender + descender) * 1.08), fontSize)
102
+ };
103
+ }
104
+ function buildFontPath(font, text, fontSize) {
105
+ const pathObject = font.getPath(text, 0, 0, fontSize);
106
+ const box = pathObject.getBoundingBox();
107
+ return {
108
+ pathData: pathObject.toPathData(3),
109
+ width: Math.max(box.x2 - box.x1, 0),
110
+ x1: box.x1,
111
+ x2: box.x2
112
+ };
113
+ }
114
+ function buildPathElement(pathData, x, y) {
115
+ return `<path d="${pathData}" transform="translate(${x.toFixed(3)} ${y.toFixed(3)})" fill="${COVER_TEXT_COLOR}" />`;
116
+ }
117
+ function buildTitlePath(font, line, index, canvasWidth, titleTop, lineHeight, ascender, fontSize) {
118
+ const glyphPath = buildFontPath(font, line, fontSize);
119
+ const baselineY = titleTop + index * lineHeight + ascender;
120
+ const startX = (canvasWidth - glyphPath.width) / 2 - glyphPath.x1;
121
+ return {
122
+ right: startX + glyphPath.x2,
123
+ path: buildPathElement(glyphPath.pathData, startX, baselineY)
124
+ };
125
+ }
126
+ function buildAuthorPath(font, text, fontSize, right, baselineY) {
127
+ const glyphPath = buildFontPath(font, text, fontSize);
128
+ const startX = right - glyphPath.x2;
129
+ return buildPathElement(glyphPath.pathData, startX, baselineY);
130
+ }
82
131
  function buildTextSvg(params) {
83
- const lines = buildTitleLines(params.title, 10);
132
+ const font = getCoverFont();
133
+ if (!font) {
134
+ return undefined;
135
+ }
136
+ const lines = buildTitleLines(params.title, DEFAULT_TITLE_LINE_LENGTH);
84
137
  const sizes = estimateFontSize(params.width, lines);
85
- const lineHeight = Math.round(sizes.title * 1.15);
138
+ const titleMetrics = getFontLineMetrics(font, sizes.title);
139
+ const authorMetrics = getFontLineMetrics(font, sizes.author);
140
+ const lineHeight = titleMetrics.lineHeight;
86
141
  const titleBlockHeight = lineHeight * lines.length;
87
142
  const titleTop = Math.round((params.height - titleBlockHeight) / 2);
88
- const fontFamily = '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif';
89
- const titleWidth = sizes.longestChars * sizes.title;
90
- const titleRight = Math.round(params.width / 2 + titleWidth / 2);
91
- const authorY = titleTop + titleBlockHeight + sizes.author + Math.round(sizes.author * 0.4);
92
- const titleText = lines
93
- .map((line, index) => {
94
- const y = titleTop + sizes.title + index * lineHeight;
95
- return `<text x="50%" y="${y}" text-anchor="middle" font-size="${sizes.title}" font-family="${escapeAttribute(fontFamily)}" fill="rgba(0,0,0,0.72)">${escapeXml(line)}</text>`;
96
- })
97
- .join('');
98
- const authorText = params.author.trim()
99
- ? `<text x="${titleRight}" y="${authorY}" text-anchor="end" font-size="${sizes.author}" font-family="${escapeAttribute(fontFamily)}" fill="rgba(0,0,0,0.72)">${escapeXml(params.author.trim())}</text>`
143
+ const titlePaths = lines.map((line, index) => buildTitlePath(font, line, index, params.width, titleTop, lineHeight, titleMetrics.ascender, sizes.title));
144
+ const titleRight = titlePaths.length > 0
145
+ ? Math.max(...titlePaths.map((item) => item.right))
146
+ : params.width / 2;
147
+ const authorText = params.author.trim();
148
+ const authorPath = authorText
149
+ ? buildAuthorPath(font, authorText, sizes.author, titleRight, titleTop + titleBlockHeight + Math.round(sizes.author * 0.4) + authorMetrics.ascender)
100
150
  : '';
101
151
  const svg = `
102
152
  <svg width="${params.width}" height="${params.height}" viewBox="0 0 ${params.width} ${params.height}" xmlns="http://www.w3.org/2000/svg">
103
- ${titleText}
104
- ${authorText}
153
+ ${titlePaths.map((item) => item.path).join('')}
154
+ ${authorPath}
105
155
  </svg>`;
106
156
  return Buffer.from(svg);
107
157
  }
@@ -137,8 +187,13 @@ async function createCover(options) {
137
187
  const background = backgroundPath
138
188
  ? (0, sharp_1.default)(backgroundPath).resize(width, height, { fit: 'cover' })
139
189
  : (0, sharp_1.default)(buildGradientBackground(width, height));
140
- await background
141
- .composite([
190
+ const textSvg = buildTextSvg({
191
+ title: options.title,
192
+ author,
193
+ width,
194
+ height
195
+ });
196
+ const overlays = [
142
197
  {
143
198
  input: {
144
199
  create: {
@@ -148,16 +203,13 @@ async function createCover(options) {
148
203
  background: { r: 255, g: 255, b: 255, alpha: 0.58 }
149
204
  }
150
205
  }
151
- },
152
- {
153
- input: buildTextSvg({
154
- title: options.title,
155
- author,
156
- width,
157
- height
158
- })
159
206
  }
160
- ])
207
+ ];
208
+ if (textSvg) {
209
+ overlays.push({ input: textSvg });
210
+ }
211
+ await background
212
+ .composite(overlays)
161
213
  .jpeg({ quality: 92 })
162
214
  .toFile(outputPath);
163
215
  return outputPath;
@@ -80,6 +80,9 @@ function normalizeMetadata(data) {
80
80
  if (typeof data.theme === 'string' && data.theme.trim()) {
81
81
  metadata.theme = data.theme.trim();
82
82
  }
83
+ if (typeof data.account === 'string' && data.account.trim()) {
84
+ metadata.account = data.account.trim();
85
+ }
83
86
  if (typeof data.cover === 'string' && data.cover.trim()) {
84
87
  metadata.cover = data.cover.trim();
85
88
  }