@wenyan-md/cli 1.0.10 → 2.0.0
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.docker.md +31 -0
- package/README.md +70 -159
- package/dist/cli.js +129 -1
- package/dist/commands/client.js +208 -0
- package/dist/commands/publish.js +22 -18
- package/dist/commands/render.js +6 -46
- package/dist/commands/serve.js +220 -0
- package/dist/commands/theme.js +25 -36
- package/dist/index.js +6 -43
- package/dist/types/commands/client.d.ts +2 -0
- package/dist/types/commands/publish.d.ts +1 -1
- package/dist/types/commands/render.d.ts +6 -5
- package/dist/types/commands/serve.d.ts +6 -0
- package/dist/types/commands/theme.d.ts +16 -2
- package/dist/types/index.d.ts +6 -2
- package/dist/types/types.d.ts +8 -6
- package/dist/types/utils.d.ts +5 -0
- package/dist/types.js +8 -1
- package/dist/utils.js +23 -2
- package/package.json +17 -4
package/README.docker.md
CHANGED
|
@@ -125,6 +125,37 @@ WECHAT_APP_SECRET=yyy
|
|
|
125
125
|
- Runtime: Node.js (bundled)
|
|
126
126
|
- Architecture: `linux/amd64`, `linux/arm64`
|
|
127
127
|
|
|
128
|
+
## Server Mode
|
|
129
|
+
|
|
130
|
+
Deploy on a cloud server with fixed IP to solve WeChat API whitelist requirements:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
docker run -d --name wenyan-server \
|
|
134
|
+
-p 3000:3000 \
|
|
135
|
+
--env-file .env \
|
|
136
|
+
caol64/wenyan-cli \
|
|
137
|
+
serve --port 3000
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Then call the REST API from your local machine:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
# Health check
|
|
144
|
+
curl http://your-server-ip:3000/health
|
|
145
|
+
|
|
146
|
+
# Render
|
|
147
|
+
curl -X POST http://your-server-ip:3000/render \
|
|
148
|
+
-H "Content-Type: application/json" \
|
|
149
|
+
-d '{"content": "# Hello World", "theme": "default"}'
|
|
150
|
+
|
|
151
|
+
# Publish
|
|
152
|
+
curl -X POST http://your-server-ip:3000/publish \
|
|
153
|
+
-H "Content-Type: application/json" \
|
|
154
|
+
-d '{"file": "/mnt/host-downloads/article.md"}'
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
> **Note:** Add your server's public IP to WeChat Official Account whitelist once, and it works permanently.
|
|
158
|
+
|
|
128
159
|
## License
|
|
129
160
|
|
|
130
161
|
Apache License Version 2.0
|
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<div align="center">
|
|
2
|
-
<img alt
|
|
2
|
+
<img alt="logo" src="https://raw.githubusercontent.com/caol64/wenyan/main/Data/256-mac.png" width="128" />
|
|
3
3
|
</div>
|
|
4
4
|
|
|
5
5
|
# 文颜 CLI
|
|
@@ -27,214 +27,121 @@
|
|
|
27
27
|
|
|
28
28
|
- [macOS App Store 版](https://github.com/caol64/wenyan) - MAC 桌面应用
|
|
29
29
|
- [跨平台桌面版](https://github.com/caol64/wenyan-pc) - Windows/Linux
|
|
30
|
-
- 👉
|
|
30
|
+
- 👉[CLI 版本](https://github.com/caol64/wenyan-cli) - 本项目
|
|
31
31
|
- [MCP 版本](https://github.com/caol64/wenyan-mcp) - AI 自动发文
|
|
32
|
+
- [UI 库](https://github.com/caol64/wenyan-ui) - 桌面应用和 Web App 共用的 UI 层封装
|
|
32
33
|
- [核心库](https://github.com/caol64/wenyan-core) - 嵌入 Node / Web 项目
|
|
33
34
|
|
|
34
|
-
##
|
|
35
|
+
## 特性
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
- 一键发布 Markdown 到微信公众号草稿箱
|
|
38
|
+
- 自动上传本地图片与封面
|
|
39
|
+
- 支持远程 Server 发布(绕过 IP 白名单限制)
|
|
40
|
+
- 内置多套精美排版主题
|
|
41
|
+
- 支持自定义主题
|
|
42
|
+
- 支持 Docker 和 npm
|
|
43
|
+
- 可作为 CI/CD 自动发文工具
|
|
44
|
+
- 可集成 AI Agent 自动发布
|
|
37
45
|
|
|
38
|
-
|
|
39
|
-
npm install -g @wenyan-md/cli
|
|
40
|
-
```
|
|
46
|
+
## 本地模式和远程客户端模式
|
|
41
47
|
|
|
42
|
-
|
|
48
|
+
文颜 CLI 支持两种运行模式:**本地模式(Local Mode)** 和 **远程客户端模式(Client–Server Mode)**。两种模式运行效果完全一致,你可以根据运行环境和网络条件选择最合适的方式。
|
|
43
49
|
|
|
44
|
-
|
|
45
|
-
wenyan --help
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
### 方式二:Docker(无需 Node 环境)
|
|
50
|
+
### 本地模式(Local Mode)
|
|
49
51
|
|
|
50
|
-
|
|
52
|
+
在本地模式下,CLI 直接调用微信公众号 API 完成图片上传和草稿发布。
|
|
51
53
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
docker pull caol64/wenyan-cli
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
**查看帮助**
|
|
59
|
-
|
|
60
|
-
```bash
|
|
61
|
-
docker run --rm caol64/wenyan-cli
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
**发布文章示例**
|
|
65
|
-
|
|
66
|
-
```bash
|
|
67
|
-
docker run --rm \
|
|
68
|
-
--env-file .env.test \
|
|
69
|
-
-e HOST_FILE_PATH=$(pwd) \
|
|
70
|
-
-v $(pwd):/mnt/host-downloads \
|
|
71
|
-
caol64/wenyan-cli \
|
|
72
|
-
publish -f ./test/publish.md -t phycat
|
|
54
|
+
```mermaid
|
|
55
|
+
flowchart LR
|
|
56
|
+
CLI[Wenyan CLI] --> Wechat[公众号 API]
|
|
73
57
|
```
|
|
74
58
|
|
|
75
|
-
|
|
76
|
-
>
|
|
77
|
-
> - 使用 `-e` 传入环境变量
|
|
78
|
-
> - 使用 `-v` 挂载本地 Markdown 文件
|
|
79
|
-
> - 容器启动即执行 `wenyan` 命令
|
|
80
|
-
|
|
81
|
-
## 基本用法
|
|
82
|
-
|
|
83
|
-
CLI 主命令:
|
|
84
|
-
|
|
85
|
-
```bash
|
|
86
|
-
wenyan <command> [options]
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
目前支持的子命令有
|
|
90
|
-
- `publish` 排版并发布到公众号草稿箱
|
|
91
|
-
- `render` 仅排版,用做测试
|
|
92
|
-
- `theme` 主题管理
|
|
93
|
-
|
|
94
|
-
## 子命令
|
|
95
|
-
|
|
96
|
-
### `publish`
|
|
97
|
-
|
|
98
|
-
将 Markdown 转换为适配微信公众号的富文本 HTML,并上传到草稿箱。
|
|
99
|
-
|
|
100
|
-
#### 参数
|
|
101
|
-
|
|
102
|
-
- `<input-content>`
|
|
103
|
-
|
|
104
|
-
Markdown 内容,可以:
|
|
105
|
-
|
|
106
|
-
- 直接作为参数传入
|
|
107
|
-
- 通过 stdin 管道输入
|
|
108
|
-
|
|
109
|
-
#### 常用选项
|
|
59
|
+
**优点:**
|
|
110
60
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
- atom-one-dark / atom-one-light / dracula / github-dark / github / monokai / solarized-dark / solarized-light / xcode
|
|
115
|
-
- `--no-mac-style`:关闭代码块 Mac 风格
|
|
116
|
-
- `--no-footnote`:关闭链接转脚注
|
|
117
|
-
- `-f <path>`:指定本地 Markdown 文件路径
|
|
118
|
-
- `-c <path>`:指定临时自定义主题路径,优先级大于`-t`
|
|
61
|
+
* 无需额外服务器
|
|
62
|
+
* 架构简单,部署成本低
|
|
63
|
+
* 适合个人开发者和本地使用
|
|
119
64
|
|
|
120
|
-
|
|
65
|
+
**限制:**
|
|
121
66
|
|
|
122
|
-
|
|
67
|
+
> ⚠️ 微信公众号 API 要求调用方 IP 必须在白名单内。如果没有固定 IP,需要频繁添加白名单。
|
|
123
68
|
|
|
124
|
-
|
|
125
|
-
wenyan publish "# Hello, Wenyan" -t lapis -h solarized-light
|
|
126
|
-
```
|
|
69
|
+
### 远程客户端模式(Client–Server Mode)
|
|
127
70
|
|
|
128
|
-
|
|
71
|
+
在此模式下,CLI 作为客户端,将发布请求发送到部署在云服务器上的 Wenyan Server,由 Server 完成微信公众号 API 调用。
|
|
129
72
|
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
```
|
|
73
|
+
```mermaid
|
|
74
|
+
flowchart LR
|
|
133
75
|
|
|
134
|
-
|
|
76
|
+
Client[Wenyan CLI Client]
|
|
77
|
+
Server[Wenyan Server]
|
|
78
|
+
Wechat[公众号 API]
|
|
135
79
|
|
|
136
|
-
|
|
137
|
-
|
|
80
|
+
Client -->|Markdown + Images| Server
|
|
81
|
+
Server -->|Upload Media| Wechat
|
|
82
|
+
Server -->|Create Draft| Wechat
|
|
83
|
+
Server -->|Result| Client
|
|
138
84
|
```
|
|
139
85
|
|
|
140
|
-
|
|
86
|
+
**优点:**
|
|
141
87
|
|
|
142
|
-
|
|
88
|
+
* 无需本地固定 IP
|
|
89
|
+
* 完美绕过微信 IP 白名单限制
|
|
90
|
+
* 支持动态 IP 环境
|
|
91
|
+
* 支持团队协作
|
|
92
|
+
* 支持 CI/CD 自动发布
|
|
93
|
+
* 支持 AI Agent 自动发布
|
|
143
94
|
|
|
144
|
-
|
|
95
|
+
### Server 模式部署
|
|
145
96
|
|
|
146
|
-
|
|
97
|
+
[文档](docs/server.md)。
|
|
147
98
|
|
|
148
|
-
|
|
99
|
+
## 安装说明
|
|
149
100
|
|
|
150
|
-
|
|
151
|
-
- `--add`:添加自定义主题(永久)
|
|
152
|
-
- `--name <name>`:主题名称
|
|
153
|
-
- `--path <path>`:主题路径(本地或网络)
|
|
154
|
-
- `--rm <name>`:删除自定义主题
|
|
155
|
-
|
|
156
|
-
#### 使用示例
|
|
157
|
-
|
|
158
|
-
列出可用主题:
|
|
101
|
+
### npm 安装(推荐)
|
|
159
102
|
|
|
160
103
|
```bash
|
|
161
|
-
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
安装自定义主题
|
|
165
|
-
|
|
166
|
-
```bash
|
|
167
|
-
wenyan theme --add --name new-theme --path https://wenyan.yuzhi.tech/manhua.css
|
|
104
|
+
npm install -g @wenyan-md/cli
|
|
168
105
|
```
|
|
169
106
|
|
|
170
|
-
|
|
107
|
+
运行:
|
|
171
108
|
|
|
172
109
|
```bash
|
|
173
|
-
wenyan
|
|
110
|
+
wenyan --help
|
|
174
111
|
```
|
|
175
112
|
|
|
176
|
-
|
|
113
|
+
### docker 安装
|
|
177
114
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
- 不安装直接使用
|
|
181
|
-
|
|
182
|
-
```bash
|
|
183
|
-
wenyan publish -f "./example.md" -c "/path/to/theme" -h solarized-light --no-mac-style
|
|
184
|
-
```
|
|
115
|
+
[文档](docs/docker.md)。
|
|
185
116
|
|
|
186
|
-
|
|
117
|
+
## 命令概览
|
|
187
118
|
|
|
188
119
|
```bash
|
|
189
|
-
wenyan
|
|
190
|
-
wenyan publish -f "./example.md" -t new-theme -h solarized-light --no-mac-style
|
|
120
|
+
wenyan <command> [options]
|
|
191
121
|
```
|
|
192
122
|
|
|
193
|
-
|
|
123
|
+
| 命令 | 说明 |
|
|
124
|
+
| ------- | --------- |
|
|
125
|
+
| [publish](docs/publish.md) | 发布文章 |
|
|
126
|
+
| render | 渲染 HTML |
|
|
127
|
+
| [theme](docs/theme.md) | 管理主题 |
|
|
128
|
+
| [serve](docs/server.md) | 启动 Server |
|
|
194
129
|
|
|
195
|
-
##
|
|
130
|
+
## 关于图片与封面自动上传
|
|
196
131
|
|
|
197
|
-
|
|
132
|
+
无论是本地模式还是远程客户端模式,文颜 CLI 都提供**极度智能**的图片处理机制:
|
|
198
133
|
|
|
199
|
-
-
|
|
200
|
-
-
|
|
134
|
+
- 识别并支持本地硬盘绝对路径(如:`/Users/xxx/image.jpg`)
|
|
135
|
+
- 识别并支持当前目录的相对路径(如:`./assets/image.png`)
|
|
136
|
+
- 识别并支持网络路径(如:`https://example.com/image.jpg`)
|
|
201
137
|
|
|
202
138
|
## 环境变量配置
|
|
203
139
|
|
|
204
|
-
|
|
140
|
+
在实际向微信公众号发文的环境(你的本地或部署 `serve` 的服务器)中,必须配置以下环境变量:
|
|
205
141
|
|
|
206
142
|
- `WECHAT_APP_ID`
|
|
207
143
|
- `WECHAT_APP_SECRET`
|
|
208
144
|
|
|
209
|
-
### macOS / Linux
|
|
210
|
-
|
|
211
|
-
临时使用:
|
|
212
|
-
|
|
213
|
-
```bash
|
|
214
|
-
WECHAT_APP_ID=xxx WECHAT_APP_SECRET=yyy wenyan publish "your markdown"
|
|
215
|
-
```
|
|
216
|
-
|
|
217
|
-
永久配置(推荐):
|
|
218
|
-
|
|
219
|
-
```bash
|
|
220
|
-
export WECHAT_APP_ID=xxx
|
|
221
|
-
export WECHAT_APP_SECRET=yyy
|
|
222
|
-
```
|
|
223
|
-
|
|
224
|
-
### Windows (PowerShell)
|
|
225
|
-
|
|
226
|
-
临时使用:
|
|
227
|
-
|
|
228
|
-
```powershell
|
|
229
|
-
$env:WECHAT_APP_ID="xxx"
|
|
230
|
-
$env:WECHAT_APP_SECRET="yyy"
|
|
231
|
-
wenyan publish example.md
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
永久设置(在环境变量里添加):
|
|
235
|
-
|
|
236
|
-
控制面板 → 系统和安全 → 系统 → 高级系统设置 → 环境变量 → 添加 `WECHAT_APP_ID` 和 `WECHAT_APP_SECRET`。
|
|
237
|
-
|
|
238
145
|
## 微信公众号 IP 白名单
|
|
239
146
|
|
|
240
147
|
> [!IMPORTANT]
|
|
@@ -251,6 +158,8 @@ wenyan publish example.md
|
|
|
251
158
|
---
|
|
252
159
|
title: 在本地跑一个大语言模型(2) - 给模型提供外部知识库
|
|
253
160
|
cover: /Users/xxx/image.jpg
|
|
161
|
+
author: xxx
|
|
162
|
+
source_url: http://
|
|
254
163
|
---
|
|
255
164
|
```
|
|
256
165
|
|
|
@@ -260,6 +169,8 @@ cover: /Users/xxx/image.jpg
|
|
|
260
169
|
- `cover` 文章封面
|
|
261
170
|
- 本地路径或网络图片
|
|
262
171
|
- 如果正文中已有图片,可省略
|
|
172
|
+
- `author` 文章作者
|
|
173
|
+
- `source_url` 原文地址
|
|
263
174
|
|
|
264
175
|
## 示例文章格式
|
|
265
176
|
|
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,132 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { publishCommand } from "./commands/publish.js";
|
|
4
|
+
import { prepareRenderContext } from "./commands/render.js";
|
|
5
|
+
import pkg from "../package.json" with { type: "json" };
|
|
6
|
+
import { addTheme, listThemes, removeTheme } from "./commands/theme.js";
|
|
7
|
+
function createProgram(version = pkg.version) {
|
|
8
|
+
const program = new Command();
|
|
9
|
+
program
|
|
10
|
+
.name("wenyan")
|
|
11
|
+
.description("A CLI for WenYan Markdown Render.")
|
|
12
|
+
.version(version, "-v, --version", "output the current version")
|
|
13
|
+
.action(() => {
|
|
14
|
+
program.outputHelp();
|
|
15
|
+
});
|
|
16
|
+
const addCommonOptions = (cmd) => {
|
|
17
|
+
return cmd
|
|
18
|
+
.argument("[input-content]", "markdown content (string input)")
|
|
19
|
+
.option("-f, --file <path>", "read markdown content from local file")
|
|
20
|
+
.option("-t, --theme <theme-id>", "ID of the theme to use", "default")
|
|
21
|
+
.option("-h, --highlight <highlight-theme-id>", "ID of the code highlight theme to use", "solarized-light")
|
|
22
|
+
.option("-c, --custom-theme <path>", "path to custom theme CSS file")
|
|
23
|
+
.option("--mac-style", "display codeblock with mac style", true)
|
|
24
|
+
.option("--no-mac-style", "disable mac style")
|
|
25
|
+
.option("--footnote", "convert link to footnote", true)
|
|
26
|
+
.option("--no-footnote", "disable footnote");
|
|
27
|
+
};
|
|
28
|
+
const pubCmd = program
|
|
29
|
+
.command("publish")
|
|
30
|
+
.description("Render a markdown file to styled HTML and publish to wechat GZH");
|
|
31
|
+
// 先添加公共选项,再追加 publish 专属选项
|
|
32
|
+
addCommonOptions(pubCmd)
|
|
33
|
+
.option("--server <url>", "Server URL to publish through (e.g. https://api.yourdomain.com)")
|
|
34
|
+
.option("--api-key <apiKey>", "API key for the remote server")
|
|
35
|
+
.action(async (inputContent, options) => {
|
|
36
|
+
await runCommandWrapper(async () => {
|
|
37
|
+
// 如果传入了 --server,则走客户端(远程)模式
|
|
38
|
+
if (options.server) {
|
|
39
|
+
options.clientVersion = version; // 将 CLI 版本传递给服务器,便于调试和兼容性处理
|
|
40
|
+
const { publishClient } = await import("./commands/client.js");
|
|
41
|
+
const mediaId = await publishClient(inputContent, options);
|
|
42
|
+
console.log(`发布成功,Media ID: ${mediaId}`);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
// 走原有的本地直接发布模式
|
|
46
|
+
const mediaId = await publishCommand(inputContent, options);
|
|
47
|
+
console.log(`发布成功,Media ID: ${mediaId}`);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
const renderCmd = program.command("render").description("Render a markdown file to styled HTML");
|
|
52
|
+
addCommonOptions(renderCmd).action(async (inputContent, options) => {
|
|
53
|
+
await runCommandWrapper(async () => {
|
|
54
|
+
const { gzhContent } = await prepareRenderContext(inputContent, options);
|
|
55
|
+
console.log(gzhContent.content);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
program
|
|
59
|
+
.command("theme")
|
|
60
|
+
.description("Manage themes")
|
|
61
|
+
.option("-l, --list", "List all available themes")
|
|
62
|
+
.option("--add", "Add a new custom theme")
|
|
63
|
+
.option("--name <name>", "Name of the new custom theme")
|
|
64
|
+
.option("--path <path>", "Path to the new custom theme CSS file")
|
|
65
|
+
.option("--rm <name>", "Name of the custom theme to remove")
|
|
66
|
+
.action(async (options) => {
|
|
67
|
+
await runCommandWrapper(async () => {
|
|
68
|
+
const { list, add, name, path, rm } = options;
|
|
69
|
+
if (list) {
|
|
70
|
+
const themes = listThemes();
|
|
71
|
+
console.log("内置主题:");
|
|
72
|
+
themes
|
|
73
|
+
.filter((theme) => theme.isBuiltin)
|
|
74
|
+
.forEach((theme) => {
|
|
75
|
+
console.log(`- ${theme.id}: ${theme.description ?? ""}`);
|
|
76
|
+
});
|
|
77
|
+
const customThemes = themes.filter((theme) => !theme.isBuiltin);
|
|
78
|
+
if (customThemes.length > 0) {
|
|
79
|
+
console.log("\n自定义主题:");
|
|
80
|
+
customThemes.forEach((theme) => {
|
|
81
|
+
console.log(`- ${theme.id}: ${theme.description ?? ""}`);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
console.log("");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (add) {
|
|
88
|
+
await addTheme(name, path);
|
|
89
|
+
console.log(`主题 "${name}" 已添加\n`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (rm) {
|
|
93
|
+
await removeTheme(rm);
|
|
94
|
+
console.log(`主题 "${rm}" 已删除\n`);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
program
|
|
99
|
+
.command("serve")
|
|
100
|
+
.description("Start a server to provide HTTP API for rendering and publishing")
|
|
101
|
+
.option("-p, --port <port>", "Port to listen on (default: 3000)", "3000")
|
|
102
|
+
.option("--api-key <apiKey>", "API key for authentication")
|
|
103
|
+
.action(async (options) => {
|
|
104
|
+
try {
|
|
105
|
+
const { serveCommand } = await import("./commands/serve.js");
|
|
106
|
+
const port = options.port ? parseInt(options.port, 10) : 3000;
|
|
107
|
+
await serveCommand({ port, version, apiKey: options.apiKey });
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
console.error(error.message);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
return program;
|
|
115
|
+
}
|
|
116
|
+
// --- 统一的错误处理包装器 ---
|
|
117
|
+
async function runCommandWrapper(action) {
|
|
118
|
+
try {
|
|
119
|
+
await action();
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (error instanceof Error) {
|
|
123
|
+
console.error(error.message);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
console.error("An unexpected error occurred:", error);
|
|
127
|
+
}
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
3
131
|
const program = createProgram();
|
|
4
132
|
program.parse(process.argv);
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
import { FormData, File } from "formdata-node";
|
|
5
|
+
import { FormDataEncoder } from "form-data-encoder";
|
|
6
|
+
import { AppError } from "../types.js";
|
|
7
|
+
import { prepareRenderContext } from "./render.js";
|
|
8
|
+
import { JSDOM } from "jsdom";
|
|
9
|
+
export async function publishClient(inputContent, options) {
|
|
10
|
+
const { theme, customTheme, highlight, macStyle, footnote, apiKey, clientVersion, server } = options;
|
|
11
|
+
const serverUrl = server.replace(/\/$/, ""); // 移除末尾的斜杠
|
|
12
|
+
const headers = {};
|
|
13
|
+
if (clientVersion) {
|
|
14
|
+
headers["x-client-version"] = clientVersion;
|
|
15
|
+
}
|
|
16
|
+
if (apiKey) {
|
|
17
|
+
headers["x-api-key"] = apiKey;
|
|
18
|
+
}
|
|
19
|
+
// ==========================================
|
|
20
|
+
// 0. 连通性与鉴权测试 (Health & Auth Check)
|
|
21
|
+
// ==========================================
|
|
22
|
+
console.log(`[Client] Checking server connection at ${serverUrl} ...`);
|
|
23
|
+
try {
|
|
24
|
+
// 1. 物理连通性与服务指纹验证
|
|
25
|
+
const healthRes = await fetch(`${serverUrl}/health`, { method: "GET" });
|
|
26
|
+
if (!healthRes.ok) {
|
|
27
|
+
throw new Error(`HTTP Error: ${healthRes.status} ${healthRes.statusText}`);
|
|
28
|
+
}
|
|
29
|
+
const healthData = await healthRes.json();
|
|
30
|
+
if (healthData.status !== "ok" || healthData.service !== "wenyan-cli") {
|
|
31
|
+
throw new Error(`Invalid server response. Make sure the server URL is correct.`);
|
|
32
|
+
}
|
|
33
|
+
console.log(`[Client] Server connected successfully (version: ${healthData.version})`);
|
|
34
|
+
// 2. 鉴权探针测试
|
|
35
|
+
console.log(`[Client] Verifying authorization...`);
|
|
36
|
+
const verifyRes = await fetch(`${serverUrl}/verify`, {
|
|
37
|
+
method: "GET",
|
|
38
|
+
headers, // 携带 x-api-key 和 x-client-version
|
|
39
|
+
});
|
|
40
|
+
if (verifyRes.status === 401) {
|
|
41
|
+
throw new Error("鉴权失败 (401):Server 拒绝访问,请检查传入的 --api-key 是否正确。");
|
|
42
|
+
}
|
|
43
|
+
if (!verifyRes.ok) {
|
|
44
|
+
throw new Error(`Verify Error: ${verifyRes.status} ${verifyRes.statusText}`);
|
|
45
|
+
}
|
|
46
|
+
console.log(`[Client] Authorization passed.`);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error.message.includes("鉴权失败")) {
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`Failed to connect to server (${serverUrl}). \nPlease check if the server is running and the network is accessible. \nDetails: ${error.message}`);
|
|
53
|
+
}
|
|
54
|
+
// ==========================================
|
|
55
|
+
// 1. 读取 markdown 文件,获取其所在目录(用于解析相对图片路径),并渲染成 HTML 格式
|
|
56
|
+
// ==========================================
|
|
57
|
+
const { gzhContent, absoluteDirPath } = await prepareRenderContext(inputContent, options);
|
|
58
|
+
if (!gzhContent.title)
|
|
59
|
+
throw new AppError("未能找到文章标题");
|
|
60
|
+
if (!gzhContent.cover)
|
|
61
|
+
throw new AppError("未能找到文章封面");
|
|
62
|
+
// ==========================================
|
|
63
|
+
// [内部辅助函数] 提取公共的本地图片上传逻辑
|
|
64
|
+
// ==========================================
|
|
65
|
+
const uploadLocalImage = async (originalUrl) => {
|
|
66
|
+
let imagePath = originalUrl;
|
|
67
|
+
if (!path.isAbsolute(imagePath)) {
|
|
68
|
+
// 如果是相对路径,以 markdown 文件所在目录为基准进行拼接
|
|
69
|
+
imagePath = path.resolve(absoluteDirPath || process.cwd(), imagePath);
|
|
70
|
+
}
|
|
71
|
+
if (!fs.existsSync(imagePath)) {
|
|
72
|
+
console.warn(`[Client] Warning: Local image not found: ${imagePath}`);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const fileBuffer = fs.readFileSync(imagePath);
|
|
77
|
+
const filename = path.basename(imagePath);
|
|
78
|
+
// 推断 Content-Type
|
|
79
|
+
const ext = path.extname(filename).toLowerCase();
|
|
80
|
+
const mimeTypes = {
|
|
81
|
+
".jpg": "image/jpeg",
|
|
82
|
+
".jpeg": "image/jpeg",
|
|
83
|
+
".png": "image/png",
|
|
84
|
+
".gif": "image/gif",
|
|
85
|
+
".webp": "image/webp",
|
|
86
|
+
".svg": "image/svg+xml",
|
|
87
|
+
};
|
|
88
|
+
const type = mimeTypes[ext] || "application/octet-stream";
|
|
89
|
+
// 构建图片上传表单
|
|
90
|
+
const form = new FormData();
|
|
91
|
+
form.append("file", new File([fileBuffer], filename, { type }));
|
|
92
|
+
const encoder = new FormDataEncoder(form);
|
|
93
|
+
console.log(`[Client] Uploading local image: ${filename} ...`);
|
|
94
|
+
const uploadRes = await fetch(`${serverUrl}/upload`, {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: { ...headers, ...encoder.headers },
|
|
97
|
+
body: Readable.from(encoder),
|
|
98
|
+
duplex: "half",
|
|
99
|
+
});
|
|
100
|
+
if (uploadRes.status === 401) {
|
|
101
|
+
throw new Error("鉴权失败 (401):Server 拒绝访问,请检查传入的 --api-key 是否正确。");
|
|
102
|
+
}
|
|
103
|
+
const uploadData = await uploadRes.json();
|
|
104
|
+
if (uploadRes.ok && uploadData.success) {
|
|
105
|
+
// 返回可供 Server 直接使用的协议路径
|
|
106
|
+
return `asset://${uploadData.data.fileId}`;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
console.warn(`[Client] Warning: Failed to upload ${filename}: ${uploadData.error || uploadData.desc}`);
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error.message.includes("鉴权失败") || error.message.includes("401")) {
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
console.warn(`[Client] Warning: Error uploading ${imagePath} - ${error.message}`);
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
// ==========================================
|
|
122
|
+
// 2. 解析 HTML 中的所有本地图片上传并替换为服务器可访问的 URL
|
|
123
|
+
// ==========================================
|
|
124
|
+
let modifiedContent = gzhContent.content;
|
|
125
|
+
if (modifiedContent.includes("<img")) {
|
|
126
|
+
const dom = new JSDOM(modifiedContent);
|
|
127
|
+
const document = dom.window.document;
|
|
128
|
+
const images = Array.from(document.querySelectorAll("img"));
|
|
129
|
+
// 并发上传所有插图
|
|
130
|
+
const uploadPromises = images.map(async (element) => {
|
|
131
|
+
const dataSrc = element.getAttribute("src");
|
|
132
|
+
if (dataSrc && needUpload(dataSrc)) {
|
|
133
|
+
const newUrl = await uploadLocalImage(dataSrc);
|
|
134
|
+
if (newUrl) {
|
|
135
|
+
element.setAttribute("src", newUrl); // 替换 DOM 中的属性
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
await Promise.all(uploadPromises);
|
|
140
|
+
modifiedContent = document.body.innerHTML;
|
|
141
|
+
gzhContent.content = modifiedContent;
|
|
142
|
+
}
|
|
143
|
+
// ==========================================
|
|
144
|
+
// 3. 处理封面图片
|
|
145
|
+
// ==========================================
|
|
146
|
+
const cover = gzhContent.cover;
|
|
147
|
+
if (cover && needUpload(cover)) {
|
|
148
|
+
console.log(`[Client] Processing cover image...`);
|
|
149
|
+
const newCoverUrl = await uploadLocalImage(cover);
|
|
150
|
+
if (newCoverUrl) {
|
|
151
|
+
gzhContent.cover = newCoverUrl; // 将封面路径替换为 asset://fileId
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// ==========================================
|
|
155
|
+
// 4. 将替换后的 content 保存成临时文件/流,并上传
|
|
156
|
+
// ==========================================
|
|
157
|
+
const mdFilename = "publish_target.json"; // 这个文件名对服务器来说没有实际意义,只是一个标识
|
|
158
|
+
const mdForm = new FormData();
|
|
159
|
+
mdForm.append("file", new File([Buffer.from(JSON.stringify(gzhContent), "utf-8")], mdFilename, { type: "application/json" }));
|
|
160
|
+
const mdEncoder = new FormDataEncoder(mdForm);
|
|
161
|
+
console.log(`[Client] Uploading compiled document ...`);
|
|
162
|
+
const mdUploadRes = await fetch(`${serverUrl}/upload`, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
headers: { ...headers, ...mdEncoder.headers },
|
|
165
|
+
body: Readable.from(mdEncoder),
|
|
166
|
+
duplex: "half",
|
|
167
|
+
});
|
|
168
|
+
if (mdUploadRes.status === 401) {
|
|
169
|
+
throw new Error("鉴权失败 (401):Server 拒绝访问,请检查传入的 --api-key 是否正确。");
|
|
170
|
+
}
|
|
171
|
+
const mdUploadData = await mdUploadRes.json();
|
|
172
|
+
if (!mdUploadRes.ok || !mdUploadData.success) {
|
|
173
|
+
throw new Error(`Upload Document Failed: ${mdUploadData.error || mdUploadData.desc || mdUploadRes.statusText}`);
|
|
174
|
+
}
|
|
175
|
+
const mdFileId = mdUploadData.data.fileId;
|
|
176
|
+
console.log(`[Client] Document uploaded, ID: ${mdFileId}`);
|
|
177
|
+
// ==========================================
|
|
178
|
+
// 5. 调用 /publish 接口,触发 Server 端发布
|
|
179
|
+
// ==========================================
|
|
180
|
+
console.log(`[Client] Requesting remote Server to publish ...`);
|
|
181
|
+
const publishRes = await fetch(`${serverUrl}/publish`, {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers: {
|
|
184
|
+
...headers,
|
|
185
|
+
"Content-Type": "application/json",
|
|
186
|
+
},
|
|
187
|
+
body: JSON.stringify({
|
|
188
|
+
fileId: mdFileId,
|
|
189
|
+
theme,
|
|
190
|
+
highlight,
|
|
191
|
+
customTheme,
|
|
192
|
+
macStyle,
|
|
193
|
+
footnote,
|
|
194
|
+
}),
|
|
195
|
+
});
|
|
196
|
+
if (publishRes.status === 401) {
|
|
197
|
+
throw new Error("鉴权失败 (401):Server 拒绝访问,请检查传入的 --api-key 是否正确。");
|
|
198
|
+
}
|
|
199
|
+
const publishData = await publishRes.json();
|
|
200
|
+
if (!publishRes.ok || publishData.code === -1) {
|
|
201
|
+
throw new Error(`Remote Publish Failed: ${publishData.desc || publishRes.statusText}`);
|
|
202
|
+
}
|
|
203
|
+
return publishData.media_id;
|
|
204
|
+
}
|
|
205
|
+
function needUpload(url) {
|
|
206
|
+
// 需要上传的图片链接通常是相对路径,且不以 http/https、data:、asset:// 等协议开头
|
|
207
|
+
return !/^(https?:\/\/|data:|asset:\/\/)/i.test(url);
|
|
208
|
+
}
|