@wenyan-md/cli 1.0.11 → 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 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 = "logo" src="https://raw.githubusercontent.com/caol64/wenyan/main/Data/256-mac.png" />
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
- - 👉 [CLI 版本](https://github.com/caol64/wenyan-cli) - 本项目
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
- ### 方式一:npm(推荐)
37
+ - 一键发布 Markdown 到微信公众号草稿箱
38
+ - 自动上传本地图片与封面
39
+ - 支持远程 Server 发布(绕过 IP 白名单限制)
40
+ - 内置多套精美排版主题
41
+ - 支持自定义主题
42
+ - 支持 Docker 和 npm
43
+ - 可作为 CI/CD 自动发文工具
44
+ - 可集成 AI Agent 自动发布
37
45
 
38
- ```bash
39
- npm install -g @wenyan-md/cli
40
- ```
46
+ ## 本地模式和远程客户端模式
41
47
 
42
- 安装完成后即可使用:
48
+ 文颜 CLI 支持两种运行模式:**本地模式(Local Mode)** 和 **远程客户端模式(Client–Server Mode)**。两种模式运行效果完全一致,你可以根据运行环境和网络条件选择最合适的方式。
43
49
 
44
- ```bash
45
- wenyan --help
46
- ```
47
-
48
- ### 方式二:Docker(无需 Node 环境)
50
+ ### 本地模式(Local Mode)
49
51
 
50
- 如果你不想在本地安装 Node.js,也可以直接使用 Docker。
52
+ 在本地模式下,CLI 直接调用微信公众号 API 完成图片上传和草稿发布。
51
53
 
52
- **拉取镜像**
53
-
54
- ```bash
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
- - `-t <theme-id>`:主题id(默认 `default`),可以是内置主题,也可以是通过`theme --add`添加的自定义主题
112
- - [内置主题](https://github.com/caol64/wenyan-core/tree/main/src/assets/themes)
113
- - `-h <highlight-theme-id>`:代码高亮主题(默认 `solarized-light`)
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
- ```bash
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
- ```bash
131
- cat example.md | wenyan publish -t lapis -h solarized-light --no-mac-style
132
- ```
73
+ ```mermaid
74
+ flowchart LR
133
75
 
134
- 从文件读取:
76
+ Client[Wenyan CLI Client]
77
+ Server[Wenyan Server]
78
+ Wechat[公众号 API]
135
79
 
136
- ```bash
137
- wenyan publish -f "./example.md" -t lapis -h solarized-light --no-mac-style
80
+ Client -->|Markdown + Images| Server
81
+ Server -->|Upload Media| Wechat
82
+ Server -->|Create Draft| Wechat
83
+ Server -->|Result| Client
138
84
  ```
139
85
 
140
- ### `theme`
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
- - `-l`:列出所有可用主题
151
- - `--add`:添加自定义主题(永久)
152
- - `--name <name>`:主题名称
153
- - `--path <path>`:主题路径(本地或网络)
154
- - `--rm <name>`:删除自定义主题
155
-
156
- #### 使用示例
157
-
158
- 列出可用主题:
101
+ ### npm 安装(推荐)
159
102
 
160
103
  ```bash
161
- wenyan theme -l
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 theme --rm new-theme
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 theme --add --name new-theme --path https://wenyan.yuzhi.tech/manhua.css
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
- - 本地路径(如:`/Users/xxx/image.jpg`)
200
- - 网络路径(如:`https://example.com/image.jpg`)
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]
package/dist/cli.js CHANGED
@@ -1,4 +1,132 @@
1
1
  #!/usr/bin/env node
2
- import { createProgram } from "./index.js";
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
+ }