@wenyan-md/core 2.0.8 → 3.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/dist/publish.js +93 -372
- package/dist/types/node/publish.d.ts +5 -10
- package/dist/types/node/tokenStoreNodeAdapter.d.ts +7 -0
- package/dist/types/node/uploadCacheNodeAdapter.d.ts +8 -0
- package/dist/types/node/wrapper.d.ts +1 -2
- package/dist/types/publish.d.ts +22 -0
- package/dist/types/{node/tokenStore.d.ts → tokenStore.d.ts} +10 -5
- package/dist/types/uploadCacheStore.d.ts +27 -0
- package/dist/types/wechat.d.ts +1 -1
- package/dist/wechat.js +5 -5
- package/dist/wrapper.js +358 -18
- package/package.json +2 -2
- package/dist/types/node/uploadCacheStore.d.ts +0 -16
package/dist/publish.js
CHANGED
|
@@ -1,222 +1,37 @@
|
|
|
1
|
-
import { JSDOM } from "jsdom";
|
|
2
|
-
import { fileFromPath } from "formdata-node/file-from-path";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import fs, { stat } from "node:fs/promises";
|
|
5
|
-
import crypto from "node:crypto";
|
|
6
1
|
import { createWechatClient } from "./wechat.js";
|
|
7
|
-
|
|
8
|
-
import { FormData } from "formdata-node";
|
|
9
|
-
import { Readable } from "node:stream";
|
|
10
|
-
import os from "node:os";
|
|
11
|
-
async function readFileContent(filePath) {
|
|
12
|
-
return await fs.readFile(filePath, "utf-8");
|
|
13
|
-
}
|
|
14
|
-
async function readBinaryFile(filePath) {
|
|
15
|
-
return await fs.readFile(filePath);
|
|
16
|
-
}
|
|
17
|
-
async function safeReadJson(file, fallback) {
|
|
18
|
-
try {
|
|
19
|
-
const content = await fs.readFile(file, "utf-8");
|
|
20
|
-
return JSON.parse(content);
|
|
21
|
-
} catch {
|
|
22
|
-
return fallback;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
async function safeWriteJson(file, data) {
|
|
26
|
-
const tmp = file + ".tmp";
|
|
27
|
-
await fs.writeFile(tmp, JSON.stringify(data ?? {}, null, 2), "utf-8");
|
|
28
|
-
await fs.rename(tmp, file);
|
|
29
|
-
}
|
|
30
|
-
async function ensureDir(dir) {
|
|
31
|
-
await fs.mkdir(dir, { recursive: true });
|
|
32
|
-
}
|
|
33
|
-
function md5FromBuffer(buf) {
|
|
34
|
-
return crypto.createHash("md5").update(buf).digest("hex");
|
|
35
|
-
}
|
|
36
|
-
async function md5FromFile(filePath) {
|
|
37
|
-
const buf = await fs.readFile(filePath);
|
|
38
|
-
return md5FromBuffer(buf);
|
|
39
|
-
}
|
|
40
|
-
function normalizePath(p) {
|
|
41
|
-
return p.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
42
|
-
}
|
|
43
|
-
function isAbsolutePath(path2) {
|
|
44
|
-
if (!path2) return false;
|
|
45
|
-
const winAbsPattern = /^[a-zA-Z]:\//;
|
|
46
|
-
const linuxAbsPattern = /^\//;
|
|
47
|
-
return winAbsPattern.test(path2) || linuxAbsPattern.test(path2);
|
|
48
|
-
}
|
|
49
|
-
function getNormalizeFilePath(inputPath) {
|
|
50
|
-
const isContainer = !!process.env.CONTAINERIZED;
|
|
51
|
-
const hostFilePath = normalizePath(process.env.HOST_FILE_PATH || "");
|
|
52
|
-
if (isContainer && hostFilePath) {
|
|
53
|
-
const containerFilePath = normalizePath(process.env.CONTAINER_FILE_PATH || "/mnt/host-downloads");
|
|
54
|
-
let relativePart = normalizePath(inputPath);
|
|
55
|
-
if (relativePart.startsWith(hostFilePath)) {
|
|
56
|
-
relativePart = relativePart.slice(hostFilePath.length);
|
|
57
|
-
}
|
|
58
|
-
if (!relativePart.startsWith("/")) {
|
|
59
|
-
relativePart = "/" + relativePart;
|
|
60
|
-
}
|
|
61
|
-
return containerFilePath + relativePart;
|
|
62
|
-
} else {
|
|
63
|
-
return path.resolve(inputPath);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
const RuntimeEnv = {
|
|
67
|
-
isContainer: !!process.env.CONTAINERIZED,
|
|
68
|
-
hostFilePath: normalizePath(process.env.HOST_FILE_PATH || ""),
|
|
69
|
-
containerFilePath: normalizePath(process.env.CONTAINER_FILE_PATH || "/mnt/host-downloads"),
|
|
70
|
-
resolveLocalPath(inputPath, relativeBase) {
|
|
71
|
-
if (!this.isContainer) {
|
|
72
|
-
if (relativeBase) {
|
|
73
|
-
return path.resolve(relativeBase, inputPath);
|
|
74
|
-
} else {
|
|
75
|
-
if (!path.isAbsolute(inputPath)) {
|
|
76
|
-
throw new Error(
|
|
77
|
-
`Invalid input: '${inputPath}'. InputPath must be an absolute path.`
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
return path.normalize(inputPath);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
let normalizedInput = normalizePath(inputPath);
|
|
84
|
-
relativeBase = normalizePath(relativeBase || "");
|
|
85
|
-
if (relativeBase) {
|
|
86
|
-
if (!isAbsolutePath(normalizedInput)) {
|
|
87
|
-
normalizedInput = relativeBase + (normalizedInput.startsWith("/") ? "" : "/") + normalizedInput;
|
|
88
|
-
}
|
|
89
|
-
} else {
|
|
90
|
-
if (!isAbsolutePath(normalizedInput)) {
|
|
91
|
-
throw new Error(
|
|
92
|
-
`Invalid input: '${inputPath}'. InputPath must be an absolute path.`
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
if (this.hostFilePath && normalizedInput.startsWith(this.hostFilePath)) {
|
|
97
|
-
let relativePart = normalizedInput.slice(this.hostFilePath.length);
|
|
98
|
-
if (relativePart && !relativePart.startsWith("/")) {
|
|
99
|
-
return normalizedInput;
|
|
100
|
-
}
|
|
101
|
-
if (!relativePart.startsWith("/")) {
|
|
102
|
-
relativePart = "/" + relativePart;
|
|
103
|
-
}
|
|
104
|
-
return this.containerFilePath + relativePart;
|
|
105
|
-
}
|
|
106
|
-
return normalizedInput;
|
|
107
|
-
}
|
|
108
|
-
};
|
|
109
|
-
const nodeHttpAdapter = {
|
|
110
|
-
fetch,
|
|
111
|
-
createMultipart(field, file, filename) {
|
|
112
|
-
const form = new FormData();
|
|
113
|
-
form.append(field, file, filename);
|
|
114
|
-
const encoder = new FormDataEncoder(form);
|
|
115
|
-
return {
|
|
116
|
-
body: Readable.from(encoder),
|
|
117
|
-
headers: encoder.headers,
|
|
118
|
-
duplex: "half"
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
};
|
|
122
|
-
const defaultConfig = {};
|
|
123
|
-
const configDir = process.env.APPDATA ? path.join(process.env.APPDATA, "wenyan-md") : path.join(os.homedir(), ".config", "wenyan-md");
|
|
124
|
-
const configPath = path.join(configDir, "config.json");
|
|
125
|
-
class ConfigStore {
|
|
126
|
-
config = { ...defaultConfig };
|
|
127
|
-
initPromise;
|
|
128
|
-
constructor() {
|
|
129
|
-
this.initPromise = this.load();
|
|
130
|
-
}
|
|
131
|
-
async load() {
|
|
132
|
-
await ensureDir(configDir);
|
|
133
|
-
this.config = await safeReadJson(configPath, defaultConfig);
|
|
134
|
-
}
|
|
135
|
-
async save() {
|
|
136
|
-
try {
|
|
137
|
-
await ensureDir(configDir);
|
|
138
|
-
await safeWriteJson(configPath, this.config);
|
|
139
|
-
} catch (error) {
|
|
140
|
-
throw new Error(`无法保存配置文件: ${error instanceof Error ? error.message : String(error)}`);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
async getConfig() {
|
|
144
|
-
await this.initPromise;
|
|
145
|
-
return this.config;
|
|
146
|
-
}
|
|
147
|
-
async getThemes() {
|
|
148
|
-
await this.initPromise;
|
|
149
|
-
return Object.values(this.config.themes ?? {});
|
|
150
|
-
}
|
|
151
|
-
async getThemeById(themeId) {
|
|
152
|
-
await this.initPromise;
|
|
153
|
-
const themeOption = this.config.themes?.[themeId];
|
|
154
|
-
if (!themeOption) return void 0;
|
|
155
|
-
const absoluteFilePath = path.join(configDir, themeOption.path);
|
|
156
|
-
try {
|
|
157
|
-
return await fs.readFile(absoluteFilePath, "utf-8");
|
|
158
|
-
} catch {
|
|
159
|
-
return void 0;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
async addThemeToConfig(name, content) {
|
|
163
|
-
await this.initPromise;
|
|
164
|
-
const savedPath = await this.addThemeFile(name, content);
|
|
165
|
-
this.config.themes ??= {};
|
|
166
|
-
this.config.themes[name] = {
|
|
167
|
-
id: name,
|
|
168
|
-
name,
|
|
169
|
-
path: savedPath
|
|
170
|
-
};
|
|
171
|
-
await this.save();
|
|
172
|
-
}
|
|
173
|
-
async addThemeFile(themeId, themeContent) {
|
|
174
|
-
const filePath = `themes/${themeId}.css`;
|
|
175
|
-
const absoluteFilePath = path.join(configDir, filePath);
|
|
176
|
-
await ensureDir(path.dirname(absoluteFilePath));
|
|
177
|
-
await fs.writeFile(absoluteFilePath, themeContent, "utf-8");
|
|
178
|
-
return filePath;
|
|
179
|
-
}
|
|
180
|
-
async deleteThemeFromConfig(themeId) {
|
|
181
|
-
await this.initPromise;
|
|
182
|
-
const theme = this.config.themes?.[themeId];
|
|
183
|
-
if (!theme) return;
|
|
184
|
-
await this.deleteThemeFile(theme.path);
|
|
185
|
-
delete this.config.themes[themeId];
|
|
186
|
-
await this.save();
|
|
187
|
-
}
|
|
188
|
-
async deleteThemeFile(filePath) {
|
|
189
|
-
try {
|
|
190
|
-
await fs.unlink(path.join(configDir, filePath));
|
|
191
|
-
} catch {
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
const configStore = new ConfigStore();
|
|
196
|
-
const tokenPath = path.join(configDir, "token.json");
|
|
197
|
-
const defaultCache = {
|
|
2
|
+
const defaultTokenCache = {
|
|
198
3
|
appid: "",
|
|
199
4
|
accessToken: "",
|
|
200
5
|
expireAt: 0
|
|
201
6
|
};
|
|
202
7
|
class TokenStore {
|
|
203
|
-
cache = { ...
|
|
8
|
+
cache = { ...defaultTokenCache };
|
|
9
|
+
adapter;
|
|
204
10
|
initPromise;
|
|
205
|
-
constructor() {
|
|
11
|
+
constructor(adapter) {
|
|
12
|
+
this.adapter = adapter;
|
|
206
13
|
this.initPromise = this.load();
|
|
207
14
|
}
|
|
208
15
|
async load() {
|
|
209
|
-
|
|
210
|
-
|
|
16
|
+
try {
|
|
17
|
+
const loadedData = await this.adapter.loadToken();
|
|
18
|
+
if (loadedData) {
|
|
19
|
+
this.cache = loadedData;
|
|
20
|
+
}
|
|
21
|
+
} catch (error) {
|
|
22
|
+
throw new Error(`无法加载 token: ${error instanceof Error ? error.message : String(error)}`);
|
|
23
|
+
}
|
|
211
24
|
}
|
|
212
25
|
async save() {
|
|
213
26
|
try {
|
|
214
|
-
await
|
|
215
|
-
await safeWriteJson(tokenPath, this.cache);
|
|
27
|
+
await this.adapter.saveToken(this.cache);
|
|
216
28
|
} catch (error) {
|
|
217
29
|
throw new Error(`无法保存 token: ${error instanceof Error ? error.message : String(error)}`);
|
|
218
30
|
}
|
|
219
31
|
}
|
|
32
|
+
async waitForInit() {
|
|
33
|
+
await this.initPromise;
|
|
34
|
+
}
|
|
220
35
|
isValid(appid) {
|
|
221
36
|
const currentTime = Math.floor(Date.now() / 1e3);
|
|
222
37
|
const bufferTime = 600;
|
|
@@ -232,209 +47,115 @@ class TokenStore {
|
|
|
232
47
|
this.cache = {
|
|
233
48
|
appid,
|
|
234
49
|
accessToken,
|
|
50
|
+
// 计算绝对过期时间戳(秒)
|
|
235
51
|
expireAt: Math.floor(Date.now() / 1e3) + expiresIn
|
|
236
52
|
};
|
|
237
53
|
await this.save();
|
|
238
54
|
}
|
|
239
55
|
async clear() {
|
|
240
56
|
await this.initPromise;
|
|
241
|
-
this.cache = { ...
|
|
242
|
-
|
|
243
|
-
await fs.unlink(tokenPath);
|
|
244
|
-
} catch {
|
|
245
|
-
await this.save();
|
|
246
|
-
}
|
|
57
|
+
this.cache = { ...defaultTokenCache };
|
|
58
|
+
await this.adapter.clearToken();
|
|
247
59
|
}
|
|
248
60
|
}
|
|
249
|
-
const tokenStore = new TokenStore();
|
|
250
|
-
const cachePath = path.join(configDir, "upload-cache.json");
|
|
251
61
|
class UploadCacheStore {
|
|
252
62
|
cache = {};
|
|
63
|
+
adapter;
|
|
253
64
|
initPromise;
|
|
254
|
-
constructor() {
|
|
65
|
+
constructor(adapter) {
|
|
66
|
+
this.adapter = adapter;
|
|
255
67
|
this.initPromise = this.load();
|
|
256
68
|
}
|
|
257
69
|
async load() {
|
|
258
|
-
|
|
259
|
-
|
|
70
|
+
try {
|
|
71
|
+
this.cache = await this.adapter.loadCache();
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw new Error(`无法加载上传缓存: ${error instanceof Error ? error.message : String(error)}`);
|
|
74
|
+
}
|
|
260
75
|
}
|
|
261
76
|
async save() {
|
|
262
77
|
try {
|
|
263
|
-
await
|
|
264
|
-
await safeWriteJson(cachePath, this.cache);
|
|
78
|
+
await this.adapter.saveCache(this.cache);
|
|
265
79
|
} catch (error) {
|
|
266
80
|
throw new Error(`无法保存上传缓存: ${error instanceof Error ? error.message : String(error)}`);
|
|
267
81
|
}
|
|
268
82
|
}
|
|
269
|
-
async
|
|
83
|
+
async waitForInit() {
|
|
270
84
|
await this.initPromise;
|
|
271
|
-
return this.cache[md5];
|
|
272
85
|
}
|
|
273
|
-
async
|
|
86
|
+
async get(hash) {
|
|
274
87
|
await this.initPromise;
|
|
275
|
-
this.cache[
|
|
88
|
+
return this.cache[hash];
|
|
89
|
+
}
|
|
90
|
+
async set(hash, mediaId, url) {
|
|
91
|
+
await this.initPromise;
|
|
92
|
+
this.cache[hash] = {
|
|
93
|
+
media_id: mediaId,
|
|
94
|
+
url,
|
|
95
|
+
updated_at: Date.now()
|
|
96
|
+
};
|
|
276
97
|
await this.save();
|
|
277
98
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const
|
|
307
|
-
finalName = fileName ?? (ext === "" ? `${fileNameFromUrl}.jpg` : fileNameFromUrl);
|
|
308
|
-
const contentType = response.headers.get("content-type") || "image/jpeg";
|
|
309
|
-
fileData = new Blob([buffer], { type: contentType });
|
|
310
|
-
} else {
|
|
311
|
-
const resolvedPath = RuntimeEnv.resolveLocalPath(imageUrl, relativePath);
|
|
312
|
-
const stats = await stat(resolvedPath);
|
|
313
|
-
if (stats.size === 0) {
|
|
314
|
-
throw new Error(`本地图片大小为0,无法上传: ${resolvedPath}`);
|
|
315
|
-
}
|
|
316
|
-
md5 = await md5FromFile(resolvedPath);
|
|
317
|
-
const cached = await uploadCacheStore.get(md5);
|
|
99
|
+
async clear() {
|
|
100
|
+
await this.initPromise;
|
|
101
|
+
this.cache = {};
|
|
102
|
+
await this.adapter.clearCache();
|
|
103
|
+
}
|
|
104
|
+
async calcHash(buffer) {
|
|
105
|
+
return this.adapter.calcHash(buffer);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
class WechatPublisher {
|
|
109
|
+
tokenStore;
|
|
110
|
+
uploadCacheStore;
|
|
111
|
+
uploadMaterial;
|
|
112
|
+
publishArticle;
|
|
113
|
+
fetchAccessToken;
|
|
114
|
+
constructor(httpAdapter, tokenStoreAdapter, uploadCacheStoreAdapter) {
|
|
115
|
+
const { uploadMaterial, publishArticle, fetchAccessToken } = createWechatClient(httpAdapter);
|
|
116
|
+
this.uploadMaterial = uploadMaterial;
|
|
117
|
+
this.publishArticle = publishArticle;
|
|
118
|
+
this.fetchAccessToken = fetchAccessToken;
|
|
119
|
+
this.tokenStore = tokenStoreAdapter ? new TokenStore(tokenStoreAdapter) : void 0;
|
|
120
|
+
this.uploadCacheStore = uploadCacheStoreAdapter ? new UploadCacheStore(uploadCacheStoreAdapter) : void 0;
|
|
121
|
+
}
|
|
122
|
+
async getAccessTokenWithCache(appId, appSecret) {
|
|
123
|
+
if (!this.tokenStore) {
|
|
124
|
+
const result2 = await this.fetchAccessToken(appId, appSecret);
|
|
125
|
+
return result2.access_token;
|
|
126
|
+
}
|
|
127
|
+
const cached = this.tokenStore.getToken(appId);
|
|
318
128
|
if (cached) {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
}
|
|
336
|
-
async function uploadImages(content, accessToken, relativePath) {
|
|
337
|
-
if (!content.includes("<img")) {
|
|
338
|
-
return { html: content, firstImageId: "" };
|
|
339
|
-
}
|
|
340
|
-
const dom = new JSDOM(content);
|
|
341
|
-
const document = dom.window.document;
|
|
342
|
-
const images = Array.from(document.querySelectorAll("img"));
|
|
343
|
-
const uploadPromises = images.map(async (element) => {
|
|
344
|
-
const dataSrc = element.getAttribute("src");
|
|
345
|
-
if (dataSrc) {
|
|
346
|
-
if (!dataSrc.startsWith("https://mmbiz.qpic.cn")) {
|
|
347
|
-
const resp = await uploadImage(dataSrc, accessToken, void 0, relativePath);
|
|
348
|
-
element.setAttribute("src", resp.url);
|
|
349
|
-
return resp.media_id;
|
|
350
|
-
} else {
|
|
351
|
-
return dataSrc;
|
|
129
|
+
return cached;
|
|
130
|
+
}
|
|
131
|
+
const result = await this.fetchAccessToken(appId, appSecret);
|
|
132
|
+
await this.tokenStore.setToken(appId, result.access_token, result.expires_in);
|
|
133
|
+
return result.access_token;
|
|
134
|
+
}
|
|
135
|
+
async uploadImage(file, filename, accessToken) {
|
|
136
|
+
let hash;
|
|
137
|
+
if (this.uploadCacheStore) {
|
|
138
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
139
|
+
hash = await this.uploadCacheStore.calcHash(arrayBuffer);
|
|
140
|
+
const cached = await this.uploadCacheStore.get(hash);
|
|
141
|
+
if (cached) {
|
|
142
|
+
return {
|
|
143
|
+
media_id: cached.media_id,
|
|
144
|
+
url: cached.url
|
|
145
|
+
};
|
|
352
146
|
}
|
|
353
147
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const firstImageId = mediaIds[0] || "";
|
|
358
|
-
const updatedHtml = dom.serialize();
|
|
359
|
-
return { html: updatedHtml, firstImageId };
|
|
360
|
-
}
|
|
361
|
-
async function publishToWechatDraft(articleOptions, publishOptions = {}) {
|
|
362
|
-
const { title, content, cover, author, source_url } = articleOptions;
|
|
363
|
-
const { appId, appSecret, relativePath } = publishOptions;
|
|
364
|
-
const appIdFinal = appId ?? process.env.WECHAT_APP_ID;
|
|
365
|
-
const appSecretFinal = appSecret ?? process.env.WECHAT_APP_SECRET;
|
|
366
|
-
if (!appIdFinal || !appSecretFinal) {
|
|
367
|
-
throw new Error("请通过参数或环境变量 WECHAT_APP_ID / WECHAT_APP_SECRET 提供公众号凭据");
|
|
368
|
-
}
|
|
369
|
-
const accessToken = await getAccessTokenWithCache(appIdFinal, appSecretFinal);
|
|
370
|
-
const { html, firstImageId } = await uploadImages(content, accessToken, relativePath);
|
|
371
|
-
let thumbMediaId = "";
|
|
372
|
-
if (cover) {
|
|
373
|
-
const cachedThumbMediaId = mediaIdMapping.get(cover);
|
|
374
|
-
if (cachedThumbMediaId) {
|
|
375
|
-
thumbMediaId = cachedThumbMediaId;
|
|
376
|
-
} else {
|
|
377
|
-
const resp = await uploadImage(cover, accessToken, "cover.jpg", relativePath);
|
|
378
|
-
thumbMediaId = resp.media_id;
|
|
148
|
+
const data = await this.uploadMaterial("image", file, filename, accessToken);
|
|
149
|
+
if (this.uploadCacheStore && hash) {
|
|
150
|
+
await this.uploadCacheStore.set(hash, data.media_id, data.url);
|
|
379
151
|
}
|
|
380
|
-
} else {
|
|
381
|
-
if (firstImageId.startsWith("https://mmbiz.qpic.cn")) {
|
|
382
|
-
const cachedThumbMediaId = mediaIdMapping.get(firstImageId);
|
|
383
|
-
if (cachedThumbMediaId) {
|
|
384
|
-
thumbMediaId = cachedThumbMediaId;
|
|
385
|
-
} else {
|
|
386
|
-
const resp = await uploadImage(firstImageId, accessToken, "cover.jpg", relativePath);
|
|
387
|
-
thumbMediaId = resp.media_id;
|
|
388
|
-
}
|
|
389
|
-
} else {
|
|
390
|
-
thumbMediaId = firstImageId;
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
if (!thumbMediaId) {
|
|
394
|
-
throw new Error("你必须指定一张封面图或者在正文中至少出现一张图片。");
|
|
395
|
-
}
|
|
396
|
-
const data = await publishArticle(accessToken, {
|
|
397
|
-
title,
|
|
398
|
-
content: html,
|
|
399
|
-
thumb_media_id: thumbMediaId,
|
|
400
|
-
author,
|
|
401
|
-
content_source_url: source_url
|
|
402
|
-
});
|
|
403
|
-
if (data.media_id) {
|
|
404
152
|
return data;
|
|
405
153
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
async function publishToDraft(title, content, cover = "", options = {}) {
|
|
409
|
-
return publishToWechatDraft({ title, content, cover }, options);
|
|
410
|
-
}
|
|
411
|
-
async function getAccessTokenWithCache(appId, appSecret) {
|
|
412
|
-
const cached = tokenStore.getToken(appId);
|
|
413
|
-
if (cached) {
|
|
414
|
-
return cached;
|
|
154
|
+
async publishToDraft(accessToken, options) {
|
|
155
|
+
return await this.publishArticle(accessToken, options);
|
|
415
156
|
}
|
|
416
|
-
const result = await fetchAccessToken(appId, appSecret);
|
|
417
|
-
await tokenStore.setToken(appId, result.access_token, result.expires_in);
|
|
418
|
-
return result.access_token;
|
|
419
157
|
}
|
|
420
158
|
export {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
configDir as b,
|
|
424
|
-
configStore as c,
|
|
425
|
-
configPath as d,
|
|
426
|
-
ensureDir as e,
|
|
427
|
-
md5FromFile as f,
|
|
428
|
-
getNormalizeFilePath as g,
|
|
429
|
-
safeWriteJson as h,
|
|
430
|
-
isAbsolutePath as i,
|
|
431
|
-
tokenStore as j,
|
|
432
|
-
md5FromBuffer as m,
|
|
433
|
-
normalizePath as n,
|
|
434
|
-
publishToDraft,
|
|
435
|
-
publishToWechatDraft,
|
|
436
|
-
readBinaryFile as r,
|
|
437
|
-
safeReadJson as s,
|
|
438
|
-
tokenPath as t,
|
|
439
|
-
uploadCacheStore as u
|
|
159
|
+
WechatPublisher,
|
|
160
|
+
defaultTokenCache as d
|
|
440
161
|
};
|
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
import { WechatPublishResponse } from "../wechat.js";
|
|
2
|
-
|
|
2
|
+
import { ArticleOptions } from "../publish.js";
|
|
3
|
+
interface PublishOptions {
|
|
3
4
|
appId?: string;
|
|
4
5
|
appSecret?: string;
|
|
5
6
|
relativePath?: string;
|
|
6
7
|
}
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
cover?: string;
|
|
11
|
-
author?: string;
|
|
12
|
-
source_url?: string;
|
|
13
|
-
}
|
|
14
|
-
export declare function publishToWechatDraft(articleOptions: ArticleOptions, publishOptions?: WechatPublishOptions): Promise<WechatPublishResponse>;
|
|
15
|
-
export declare function publishToDraft(title: string, content: string, cover?: string, options?: WechatPublishOptions): Promise<WechatPublishResponse>;
|
|
8
|
+
export declare function publishToWechatDraft(articleOptions: ArticleOptions, publishOptions?: PublishOptions): Promise<WechatPublishResponse>;
|
|
9
|
+
export declare function publishToDraft(title: string, content: string, cover?: string, options?: PublishOptions): Promise<WechatPublishResponse>;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { TokenCache, TokenStorageAdapter } from "../tokenStore.js";
|
|
2
|
+
export declare const tokenPath: string;
|
|
3
|
+
export declare class NodeTokenStorageAdapter implements TokenStorageAdapter {
|
|
4
|
+
loadToken(): Promise<TokenCache | null>;
|
|
5
|
+
saveToken(cache: TokenCache): Promise<void>;
|
|
6
|
+
clearToken(): Promise<void>;
|
|
7
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CacheData, UploadCacheStorageAdapter } from "../uploadCacheStore.js";
|
|
2
|
+
export declare const cachePath: string;
|
|
3
|
+
export declare class NodeUploadCacheAdapter implements UploadCacheStorageAdapter {
|
|
4
|
+
loadCache(): Promise<CacheData>;
|
|
5
|
+
saveCache(cache: CacheData): Promise<void>;
|
|
6
|
+
clearCache(): Promise<void>;
|
|
7
|
+
calcHash(buffer: ArrayBuffer): Promise<string>;
|
|
8
|
+
}
|
|
@@ -3,9 +3,8 @@ export declare function getGzhContent(content: string, themeId: string, hlThemeI
|
|
|
3
3
|
export declare function renderAndPublish(inputContent: string | undefined, options: PublishOptions, getInputContent: GetInputContentFn): Promise<string>;
|
|
4
4
|
export declare function renderAndPublishToServer(inputContent: string | undefined, options: ClientPublishOptions, getInputContent: GetInputContentFn): Promise<string>;
|
|
5
5
|
export * from "./configStore.js";
|
|
6
|
-
export * from "./uploadCacheStore.js";
|
|
7
|
-
export * from "./tokenStore.js";
|
|
8
6
|
export * from "./render.js";
|
|
9
7
|
export * from "./theme.js";
|
|
10
8
|
export * from "./types.js";
|
|
11
9
|
export * from "./utils.js";
|
|
10
|
+
export * from "./publish.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { HttpAdapter } from "./http.js";
|
|
2
|
+
import { TokenStorageAdapter } from "./tokenStore.js";
|
|
3
|
+
import { UploadCacheStorageAdapter } from "./uploadCacheStore.js";
|
|
4
|
+
import { WechatPublishOptions, WechatPublishResponse, WechatUploadResponse } from "./wechat.js";
|
|
5
|
+
export interface ArticleOptions {
|
|
6
|
+
title: string;
|
|
7
|
+
content: string;
|
|
8
|
+
cover?: string;
|
|
9
|
+
author?: string;
|
|
10
|
+
source_url?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare class WechatPublisher {
|
|
13
|
+
private tokenStore;
|
|
14
|
+
private uploadCacheStore;
|
|
15
|
+
private uploadMaterial;
|
|
16
|
+
private publishArticle;
|
|
17
|
+
private fetchAccessToken;
|
|
18
|
+
constructor(httpAdapter: HttpAdapter, tokenStoreAdapter?: TokenStorageAdapter, uploadCacheStoreAdapter?: UploadCacheStorageAdapter);
|
|
19
|
+
getAccessTokenWithCache(appId: string, appSecret: string): Promise<string>;
|
|
20
|
+
uploadImage(file: Blob, filename: string, accessToken: string): Promise<WechatUploadResponse>;
|
|
21
|
+
publishToDraft(accessToken: string, options: WechatPublishOptions): Promise<WechatPublishResponse>;
|
|
22
|
+
}
|
|
@@ -1,19 +1,24 @@
|
|
|
1
|
-
export declare const tokenPath: string;
|
|
2
1
|
export interface TokenCache {
|
|
3
2
|
appid: string;
|
|
4
3
|
accessToken: string;
|
|
5
4
|
expireAt: number;
|
|
6
5
|
}
|
|
7
|
-
declare
|
|
6
|
+
export declare const defaultTokenCache: TokenCache;
|
|
7
|
+
export interface TokenStorageAdapter {
|
|
8
|
+
loadToken(): Promise<TokenCache | null>;
|
|
9
|
+
saveToken(cache: TokenCache): Promise<void>;
|
|
10
|
+
clearToken(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export declare class TokenStore {
|
|
8
13
|
private cache;
|
|
14
|
+
private adapter;
|
|
9
15
|
private initPromise;
|
|
10
|
-
constructor();
|
|
16
|
+
constructor(adapter: TokenStorageAdapter);
|
|
11
17
|
private load;
|
|
12
18
|
private save;
|
|
19
|
+
waitForInit(): Promise<void>;
|
|
13
20
|
isValid(appid: string): boolean;
|
|
14
21
|
getToken(appid: string): string | null;
|
|
15
22
|
setToken(appid: string, accessToken: string, expiresIn: number): Promise<void>;
|
|
16
23
|
clear(): Promise<void>;
|
|
17
24
|
}
|
|
18
|
-
export declare const tokenStore: TokenStore;
|
|
19
|
-
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface MediaInfo {
|
|
2
|
+
media_id: string;
|
|
3
|
+
url: string;
|
|
4
|
+
updated_at?: number;
|
|
5
|
+
}
|
|
6
|
+
export interface CacheData {
|
|
7
|
+
[hash: string]: MediaInfo;
|
|
8
|
+
}
|
|
9
|
+
export interface UploadCacheStorageAdapter {
|
|
10
|
+
loadCache(): Promise<CacheData>;
|
|
11
|
+
saveCache(cache: CacheData): Promise<void>;
|
|
12
|
+
clearCache(): Promise<void>;
|
|
13
|
+
calcHash(buffer: ArrayBuffer): Promise<string>;
|
|
14
|
+
}
|
|
15
|
+
export declare class UploadCacheStore {
|
|
16
|
+
private cache;
|
|
17
|
+
private adapter;
|
|
18
|
+
private initPromise;
|
|
19
|
+
constructor(adapter: UploadCacheStorageAdapter);
|
|
20
|
+
private load;
|
|
21
|
+
private save;
|
|
22
|
+
waitForInit(): Promise<void>;
|
|
23
|
+
get(hash: string): Promise<MediaInfo | undefined>;
|
|
24
|
+
set(hash: string, mediaId: string, url: string): Promise<void>;
|
|
25
|
+
clear(): Promise<void>;
|
|
26
|
+
calcHash(buffer: ArrayBuffer): Promise<string>;
|
|
27
|
+
}
|
package/dist/types/wechat.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ export interface WechatTokenResponse {
|
|
|
21
21
|
export interface WechatPublishResponse {
|
|
22
22
|
media_id: string;
|
|
23
23
|
}
|
|
24
|
-
export declare function createWechatClient(
|
|
24
|
+
export declare function createWechatClient(httpAdapter: HttpAdapter): {
|
|
25
25
|
fetchAccessToken(appId: string, appSecret: string): Promise<WechatTokenResponse>;
|
|
26
26
|
uploadMaterial(type: string, file: Blob, filename: string, accessToken: string): Promise<WechatUploadResponse>;
|
|
27
27
|
publishArticle(accessToken: string, options: WechatPublishOptions): Promise<WechatPublishResponse>;
|
package/dist/wechat.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
const tokenUrl = "https://api.weixin.qq.com/cgi-bin/token";
|
|
2
2
|
const publishUrl = "https://api.weixin.qq.com/cgi-bin/draft/add";
|
|
3
3
|
const uploadUrl = "https://api.weixin.qq.com/cgi-bin/material/add_material";
|
|
4
|
-
function createWechatClient(
|
|
4
|
+
function createWechatClient(httpAdapter) {
|
|
5
5
|
return {
|
|
6
6
|
async fetchAccessToken(appId, appSecret) {
|
|
7
|
-
const res = await
|
|
7
|
+
const res = await httpAdapter.fetch(
|
|
8
8
|
`${tokenUrl}?grant_type=client_credential&appid=${appId}&secret=${appSecret}`
|
|
9
9
|
);
|
|
10
10
|
if (!res.ok) throw new Error(await res.text());
|
|
@@ -13,8 +13,8 @@ function createWechatClient(adapter) {
|
|
|
13
13
|
return data;
|
|
14
14
|
},
|
|
15
15
|
async uploadMaterial(type, file, filename, accessToken) {
|
|
16
|
-
const multipart =
|
|
17
|
-
const res = await
|
|
16
|
+
const multipart = httpAdapter.createMultipart("media", file, filename);
|
|
17
|
+
const res = await httpAdapter.fetch(`${uploadUrl}?access_token=${accessToken}&type=${type}`, {
|
|
18
18
|
...multipart,
|
|
19
19
|
method: "POST"
|
|
20
20
|
});
|
|
@@ -27,7 +27,7 @@ function createWechatClient(adapter) {
|
|
|
27
27
|
return data;
|
|
28
28
|
},
|
|
29
29
|
async publishArticle(accessToken, options) {
|
|
30
|
-
const res = await
|
|
30
|
+
const res = await httpAdapter.fetch(`${publishUrl}?access_token=${accessToken}`, {
|
|
31
31
|
method: "POST",
|
|
32
32
|
body: JSON.stringify({
|
|
33
33
|
articles: [options]
|
package/dist/wrapper.js
CHANGED
|
@@ -3,9 +3,110 @@ import { Readable } from "node:stream";
|
|
|
3
3
|
import { FormData, File } from "formdata-node";
|
|
4
4
|
import { FormDataEncoder } from "form-data-encoder";
|
|
5
5
|
import { JSDOM } from "jsdom";
|
|
6
|
-
import
|
|
7
|
-
import
|
|
6
|
+
import fs, { stat } from "node:fs/promises";
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import { fileFromPath } from "formdata-node/file-from-path";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import { d as defaultTokenCache, WechatPublisher } from "./publish.js";
|
|
8
11
|
import { createWenyanCore, getAllGzhThemes } from "./core.js";
|
|
12
|
+
async function readFileContent(filePath) {
|
|
13
|
+
return await fs.readFile(filePath, "utf-8");
|
|
14
|
+
}
|
|
15
|
+
async function readBinaryFile(filePath) {
|
|
16
|
+
return await fs.readFile(filePath);
|
|
17
|
+
}
|
|
18
|
+
async function safeReadJson(file, fallback) {
|
|
19
|
+
try {
|
|
20
|
+
const content = await fs.readFile(file, "utf-8");
|
|
21
|
+
return JSON.parse(content);
|
|
22
|
+
} catch {
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async function safeWriteJson(file, data) {
|
|
27
|
+
const tmp = file + ".tmp";
|
|
28
|
+
await fs.writeFile(tmp, JSON.stringify(data ?? {}, null, 2), "utf-8");
|
|
29
|
+
await fs.rename(tmp, file);
|
|
30
|
+
}
|
|
31
|
+
async function ensureDir(dir) {
|
|
32
|
+
await fs.mkdir(dir, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
function md5FromBuffer(buf) {
|
|
35
|
+
return crypto.createHash("md5").update(buf).digest("hex");
|
|
36
|
+
}
|
|
37
|
+
async function md5FromFile(filePath) {
|
|
38
|
+
const buf = await fs.readFile(filePath);
|
|
39
|
+
return md5FromBuffer(buf);
|
|
40
|
+
}
|
|
41
|
+
function normalizePath(p) {
|
|
42
|
+
return p.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
43
|
+
}
|
|
44
|
+
function isAbsolutePath(path2) {
|
|
45
|
+
if (!path2) return false;
|
|
46
|
+
const winAbsPattern = /^[a-zA-Z]:\//;
|
|
47
|
+
const linuxAbsPattern = /^\//;
|
|
48
|
+
return winAbsPattern.test(path2) || linuxAbsPattern.test(path2);
|
|
49
|
+
}
|
|
50
|
+
function getNormalizeFilePath(inputPath) {
|
|
51
|
+
const isContainer = !!process.env.CONTAINERIZED;
|
|
52
|
+
const hostFilePath = normalizePath(process.env.HOST_FILE_PATH || "");
|
|
53
|
+
if (isContainer && hostFilePath) {
|
|
54
|
+
const containerFilePath = normalizePath(process.env.CONTAINER_FILE_PATH || "/mnt/host-downloads");
|
|
55
|
+
let relativePart = normalizePath(inputPath);
|
|
56
|
+
if (relativePart.startsWith(hostFilePath)) {
|
|
57
|
+
relativePart = relativePart.slice(hostFilePath.length);
|
|
58
|
+
}
|
|
59
|
+
if (!relativePart.startsWith("/")) {
|
|
60
|
+
relativePart = "/" + relativePart;
|
|
61
|
+
}
|
|
62
|
+
return containerFilePath + relativePart;
|
|
63
|
+
} else {
|
|
64
|
+
return path.resolve(inputPath);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const RuntimeEnv = {
|
|
68
|
+
isContainer: !!process.env.CONTAINERIZED,
|
|
69
|
+
hostFilePath: normalizePath(process.env.HOST_FILE_PATH || ""),
|
|
70
|
+
containerFilePath: normalizePath(process.env.CONTAINER_FILE_PATH || "/mnt/host-downloads"),
|
|
71
|
+
resolveLocalPath(inputPath, relativeBase) {
|
|
72
|
+
if (!this.isContainer) {
|
|
73
|
+
if (relativeBase) {
|
|
74
|
+
return path.resolve(relativeBase, inputPath);
|
|
75
|
+
} else {
|
|
76
|
+
if (!path.isAbsolute(inputPath)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Invalid input: '${inputPath}'. InputPath must be an absolute path.`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return path.normalize(inputPath);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
let normalizedInput = normalizePath(inputPath);
|
|
85
|
+
relativeBase = normalizePath(relativeBase || "");
|
|
86
|
+
if (relativeBase) {
|
|
87
|
+
if (!isAbsolutePath(normalizedInput)) {
|
|
88
|
+
normalizedInput = relativeBase + (normalizedInput.startsWith("/") ? "" : "/") + normalizedInput;
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
if (!isAbsolutePath(normalizedInput)) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Invalid input: '${inputPath}'. InputPath must be an absolute path.`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (this.hostFilePath && normalizedInput.startsWith(this.hostFilePath)) {
|
|
98
|
+
let relativePart = normalizedInput.slice(this.hostFilePath.length);
|
|
99
|
+
if (relativePart && !relativePart.startsWith("/")) {
|
|
100
|
+
return normalizedInput;
|
|
101
|
+
}
|
|
102
|
+
if (!relativePart.startsWith("/")) {
|
|
103
|
+
relativePart = "/" + relativePart;
|
|
104
|
+
}
|
|
105
|
+
return this.containerFilePath + relativePart;
|
|
106
|
+
}
|
|
107
|
+
return normalizedInput;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
9
110
|
function getServerUrl(options) {
|
|
10
111
|
let serverUrl = options.server || "http://localhost:3000";
|
|
11
112
|
serverUrl = serverUrl.replace(/\/$/, "");
|
|
@@ -168,6 +269,246 @@ async function uploadCover(serverUrl, headers, cover, relativePath) {
|
|
|
168
269
|
}
|
|
169
270
|
return cover;
|
|
170
271
|
}
|
|
272
|
+
const nodeHttpAdapter = {
|
|
273
|
+
fetch,
|
|
274
|
+
createMultipart(field, file, filename) {
|
|
275
|
+
const form = new FormData();
|
|
276
|
+
form.append(field, file, filename);
|
|
277
|
+
const encoder = new FormDataEncoder(form);
|
|
278
|
+
return {
|
|
279
|
+
body: Readable.from(encoder),
|
|
280
|
+
headers: encoder.headers,
|
|
281
|
+
duplex: "half"
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
const defaultConfig = {};
|
|
286
|
+
const configDir = process.env.APPDATA ? path.join(process.env.APPDATA, "wenyan-md") : path.join(os.homedir(), ".config", "wenyan-md");
|
|
287
|
+
const configPath = path.join(configDir, "config.json");
|
|
288
|
+
class ConfigStore {
|
|
289
|
+
config = { ...defaultConfig };
|
|
290
|
+
initPromise;
|
|
291
|
+
constructor() {
|
|
292
|
+
this.initPromise = this.load();
|
|
293
|
+
}
|
|
294
|
+
async load() {
|
|
295
|
+
await ensureDir(configDir);
|
|
296
|
+
this.config = await safeReadJson(configPath, defaultConfig);
|
|
297
|
+
}
|
|
298
|
+
async save() {
|
|
299
|
+
try {
|
|
300
|
+
await ensureDir(configDir);
|
|
301
|
+
await safeWriteJson(configPath, this.config);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
throw new Error(`无法保存配置文件: ${error instanceof Error ? error.message : String(error)}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
async getConfig() {
|
|
307
|
+
await this.initPromise;
|
|
308
|
+
return this.config;
|
|
309
|
+
}
|
|
310
|
+
async getThemes() {
|
|
311
|
+
await this.initPromise;
|
|
312
|
+
return Object.values(this.config.themes ?? {});
|
|
313
|
+
}
|
|
314
|
+
async getThemeById(themeId) {
|
|
315
|
+
await this.initPromise;
|
|
316
|
+
const themeOption = this.config.themes?.[themeId];
|
|
317
|
+
if (!themeOption) return void 0;
|
|
318
|
+
const absoluteFilePath = path.join(configDir, themeOption.path);
|
|
319
|
+
try {
|
|
320
|
+
return await fs.readFile(absoluteFilePath, "utf-8");
|
|
321
|
+
} catch {
|
|
322
|
+
return void 0;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
async addThemeToConfig(name, content) {
|
|
326
|
+
await this.initPromise;
|
|
327
|
+
const savedPath = await this.addThemeFile(name, content);
|
|
328
|
+
this.config.themes ??= {};
|
|
329
|
+
this.config.themes[name] = {
|
|
330
|
+
id: name,
|
|
331
|
+
name,
|
|
332
|
+
path: savedPath
|
|
333
|
+
};
|
|
334
|
+
await this.save();
|
|
335
|
+
}
|
|
336
|
+
async addThemeFile(themeId, themeContent) {
|
|
337
|
+
const filePath = `themes/${themeId}.css`;
|
|
338
|
+
const absoluteFilePath = path.join(configDir, filePath);
|
|
339
|
+
await ensureDir(path.dirname(absoluteFilePath));
|
|
340
|
+
await fs.writeFile(absoluteFilePath, themeContent, "utf-8");
|
|
341
|
+
return filePath;
|
|
342
|
+
}
|
|
343
|
+
async deleteThemeFromConfig(themeId) {
|
|
344
|
+
await this.initPromise;
|
|
345
|
+
const theme = this.config.themes?.[themeId];
|
|
346
|
+
if (!theme) return;
|
|
347
|
+
await this.deleteThemeFile(theme.path);
|
|
348
|
+
delete this.config.themes[themeId];
|
|
349
|
+
await this.save();
|
|
350
|
+
}
|
|
351
|
+
async deleteThemeFile(filePath) {
|
|
352
|
+
try {
|
|
353
|
+
await fs.unlink(path.join(configDir, filePath));
|
|
354
|
+
} catch {
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const configStore = new ConfigStore();
|
|
359
|
+
const tokenPath = path.join(configDir, "token.json");
|
|
360
|
+
class NodeTokenStorageAdapter {
|
|
361
|
+
async loadToken() {
|
|
362
|
+
await ensureDir(configDir);
|
|
363
|
+
return await safeReadJson(tokenPath, defaultTokenCache);
|
|
364
|
+
}
|
|
365
|
+
async saveToken(cache) {
|
|
366
|
+
await ensureDir(configDir);
|
|
367
|
+
await safeWriteJson(tokenPath, cache);
|
|
368
|
+
}
|
|
369
|
+
async clearToken() {
|
|
370
|
+
try {
|
|
371
|
+
await fs.unlink(tokenPath);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
if (error.code !== "ENOENT") {
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const cachePath = path.join(configDir, "upload-cache.json");
|
|
380
|
+
class NodeUploadCacheAdapter {
|
|
381
|
+
async loadCache() {
|
|
382
|
+
await ensureDir(configDir);
|
|
383
|
+
return await safeReadJson(cachePath, {});
|
|
384
|
+
}
|
|
385
|
+
async saveCache(cache) {
|
|
386
|
+
await ensureDir(configDir);
|
|
387
|
+
await safeWriteJson(cachePath, cache);
|
|
388
|
+
}
|
|
389
|
+
async clearCache() {
|
|
390
|
+
try {
|
|
391
|
+
await fs.unlink(cachePath);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
if (error.code !== "ENOENT") {
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
async calcHash(buffer) {
|
|
399
|
+
return crypto.createHash("md5").update(Buffer.from(buffer)).digest("hex");
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const mediaIdMapping = /* @__PURE__ */ new Map();
|
|
403
|
+
const wechatPublisher = new WechatPublisher(nodeHttpAdapter, new NodeTokenStorageAdapter(), new NodeUploadCacheAdapter());
|
|
404
|
+
async function uploadImage(imageUrl, accessToken, fileName, relativePath) {
|
|
405
|
+
let fileData;
|
|
406
|
+
let finalName;
|
|
407
|
+
if (imageUrl.startsWith("http")) {
|
|
408
|
+
const response = await fetch(imageUrl);
|
|
409
|
+
if (!response.ok || !response.body) {
|
|
410
|
+
throw new Error(`下载图片失败 URL: ${imageUrl}`);
|
|
411
|
+
}
|
|
412
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
413
|
+
if (arrayBuffer.byteLength === 0) {
|
|
414
|
+
throw new Error(`远程图片大小为0,无法上传: ${imageUrl}`);
|
|
415
|
+
}
|
|
416
|
+
const fileNameFromUrl = path.basename(imageUrl.split("?")[0]);
|
|
417
|
+
const ext = path.extname(fileNameFromUrl);
|
|
418
|
+
finalName = fileName ?? (ext === "" ? `${fileNameFromUrl}.jpg` : fileNameFromUrl);
|
|
419
|
+
const contentType = response.headers.get("content-type") || "image/jpeg";
|
|
420
|
+
fileData = new Blob([arrayBuffer], { type: contentType });
|
|
421
|
+
} else {
|
|
422
|
+
const resolvedPath = RuntimeEnv.resolveLocalPath(imageUrl, relativePath);
|
|
423
|
+
const stats = await stat(resolvedPath);
|
|
424
|
+
if (stats.size === 0) {
|
|
425
|
+
throw new Error(`本地图片大小为0,无法上传: ${resolvedPath}`);
|
|
426
|
+
}
|
|
427
|
+
const fileNameFromLocal = path.basename(resolvedPath);
|
|
428
|
+
const ext = path.extname(fileNameFromLocal);
|
|
429
|
+
finalName = fileName ?? (ext === "" ? `${fileNameFromLocal}.jpg` : fileNameFromLocal);
|
|
430
|
+
const fileFromPathResult = await fileFromPath(resolvedPath);
|
|
431
|
+
fileData = new Blob([await fileFromPathResult.arrayBuffer()], { type: fileFromPathResult.type });
|
|
432
|
+
}
|
|
433
|
+
const data = await wechatPublisher.uploadImage(fileData, finalName, accessToken);
|
|
434
|
+
mediaIdMapping.set(data.url, data.media_id);
|
|
435
|
+
return data;
|
|
436
|
+
}
|
|
437
|
+
async function uploadImages(content, accessToken, relativePath) {
|
|
438
|
+
if (!content.includes("<img")) {
|
|
439
|
+
return { html: content, firstImageId: "" };
|
|
440
|
+
}
|
|
441
|
+
const dom = new JSDOM(content);
|
|
442
|
+
const document = dom.window.document;
|
|
443
|
+
const images = Array.from(document.querySelectorAll("img"));
|
|
444
|
+
const uploadPromises = images.map(async (element) => {
|
|
445
|
+
const dataSrc = element.getAttribute("src");
|
|
446
|
+
if (dataSrc) {
|
|
447
|
+
if (!dataSrc.startsWith("https://mmbiz.qpic.cn")) {
|
|
448
|
+
const resp = await uploadImage(dataSrc, accessToken, void 0, relativePath);
|
|
449
|
+
element.setAttribute("src", resp.url);
|
|
450
|
+
return resp.media_id;
|
|
451
|
+
} else {
|
|
452
|
+
return dataSrc;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return null;
|
|
456
|
+
});
|
|
457
|
+
const mediaIds = (await Promise.all(uploadPromises)).filter(Boolean);
|
|
458
|
+
const firstImageId = mediaIds[0] || "";
|
|
459
|
+
const updatedHtml = dom.serialize();
|
|
460
|
+
return { html: updatedHtml, firstImageId };
|
|
461
|
+
}
|
|
462
|
+
async function publishToWechatDraft(articleOptions, publishOptions = {}) {
|
|
463
|
+
const { title, content, cover, author, source_url } = articleOptions;
|
|
464
|
+
const { appId, appSecret, relativePath } = publishOptions;
|
|
465
|
+
const appIdFinal = appId ?? process.env.WECHAT_APP_ID;
|
|
466
|
+
const appSecretFinal = appSecret ?? process.env.WECHAT_APP_SECRET;
|
|
467
|
+
if (!appIdFinal || !appSecretFinal) {
|
|
468
|
+
throw new Error("请通过参数或环境变量 WECHAT_APP_ID / WECHAT_APP_SECRET 提供公众号凭据");
|
|
469
|
+
}
|
|
470
|
+
const accessToken = await wechatPublisher.getAccessTokenWithCache(appIdFinal, appSecretFinal);
|
|
471
|
+
const { html, firstImageId } = await uploadImages(content, accessToken, relativePath);
|
|
472
|
+
let thumbMediaId = "";
|
|
473
|
+
if (cover) {
|
|
474
|
+
const cachedThumbMediaId = mediaIdMapping.get(cover);
|
|
475
|
+
if (cachedThumbMediaId) {
|
|
476
|
+
thumbMediaId = cachedThumbMediaId;
|
|
477
|
+
} else {
|
|
478
|
+
const resp = await uploadImage(cover, accessToken, "cover.jpg", relativePath);
|
|
479
|
+
thumbMediaId = resp.media_id;
|
|
480
|
+
}
|
|
481
|
+
} else {
|
|
482
|
+
if (firstImageId.startsWith("https://mmbiz.qpic.cn")) {
|
|
483
|
+
const cachedThumbMediaId = mediaIdMapping.get(firstImageId);
|
|
484
|
+
if (cachedThumbMediaId) {
|
|
485
|
+
thumbMediaId = cachedThumbMediaId;
|
|
486
|
+
} else {
|
|
487
|
+
const resp = await uploadImage(firstImageId, accessToken, "cover.jpg", relativePath);
|
|
488
|
+
thumbMediaId = resp.media_id;
|
|
489
|
+
}
|
|
490
|
+
} else {
|
|
491
|
+
thumbMediaId = firstImageId;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (!thumbMediaId) {
|
|
495
|
+
throw new Error("你必须指定一张封面图或者在正文中至少出现一张图片。");
|
|
496
|
+
}
|
|
497
|
+
const data = await wechatPublisher.publishToDraft(accessToken, {
|
|
498
|
+
title,
|
|
499
|
+
content: html,
|
|
500
|
+
thumb_media_id: thumbMediaId,
|
|
501
|
+
author,
|
|
502
|
+
content_source_url: source_url
|
|
503
|
+
});
|
|
504
|
+
if (data.media_id) {
|
|
505
|
+
return data;
|
|
506
|
+
}
|
|
507
|
+
throw new Error(`上传到公众号草稿失败: ${JSON.stringify(data)}`);
|
|
508
|
+
}
|
|
509
|
+
async function publishToDraft(title, content, cover = "", options = {}) {
|
|
510
|
+
return publishToWechatDraft({ title, content, cover }, options);
|
|
511
|
+
}
|
|
171
512
|
const wenyanCoreInstance = await createWenyanCore();
|
|
172
513
|
async function renderWithTheme(markdownContent, options) {
|
|
173
514
|
if (!markdownContent) {
|
|
@@ -176,8 +517,8 @@ async function renderWithTheme(markdownContent, options) {
|
|
|
176
517
|
const { theme, customTheme, highlight, macStyle, footnote } = options;
|
|
177
518
|
let handledCustomTheme = customTheme;
|
|
178
519
|
if (customTheme) {
|
|
179
|
-
const
|
|
180
|
-
handledCustomTheme = await readFileContent(
|
|
520
|
+
const normalizePath2 = getNormalizeFilePath(customTheme);
|
|
521
|
+
handledCustomTheme = await readFileContent(normalizePath2);
|
|
181
522
|
} else if (theme) {
|
|
182
523
|
handledCustomTheme = await configStore.getThemeById(theme);
|
|
183
524
|
}
|
|
@@ -252,8 +593,8 @@ async function addTheme(name, path2) {
|
|
|
252
593
|
const content = await response.text();
|
|
253
594
|
await configStore.addThemeToConfig(name, content);
|
|
254
595
|
} else {
|
|
255
|
-
const
|
|
256
|
-
const content = await readFileContent(
|
|
596
|
+
const normalizePath2 = getNormalizeFilePath(path2);
|
|
597
|
+
const content = await readFileContent(normalizePath2);
|
|
257
598
|
await configStore.addThemeToConfig(name, content);
|
|
258
599
|
}
|
|
259
600
|
}
|
|
@@ -318,18 +659,20 @@ async function renderAndPublishToServer(inputContent, options, getInputContent)
|
|
|
318
659
|
}
|
|
319
660
|
export {
|
|
320
661
|
addTheme,
|
|
321
|
-
|
|
322
|
-
|
|
662
|
+
configDir,
|
|
663
|
+
configPath,
|
|
323
664
|
configStore,
|
|
324
|
-
|
|
665
|
+
ensureDir,
|
|
325
666
|
getGzhContent,
|
|
326
667
|
getNormalizeFilePath,
|
|
327
|
-
|
|
668
|
+
isAbsolutePath,
|
|
328
669
|
listThemes,
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
670
|
+
md5FromBuffer,
|
|
671
|
+
md5FromFile,
|
|
672
|
+
normalizePath,
|
|
332
673
|
prepareRenderContext,
|
|
674
|
+
publishToDraft,
|
|
675
|
+
publishToWechatDraft,
|
|
333
676
|
readBinaryFile,
|
|
334
677
|
readFileContent,
|
|
335
678
|
removeTheme,
|
|
@@ -337,9 +680,6 @@ export {
|
|
|
337
680
|
renderAndPublishToServer,
|
|
338
681
|
renderStyledContent,
|
|
339
682
|
renderWithTheme,
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
t as tokenPath,
|
|
343
|
-
j as tokenStore,
|
|
344
|
-
u as uploadCacheStore
|
|
683
|
+
safeReadJson,
|
|
684
|
+
safeWriteJson
|
|
345
685
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenyan-md/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Core library for Wenyan markdown rendering & publishing",
|
|
5
5
|
"author": "Lei <caol64@gmail.com> (https://github.com/caol64)",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"./publish": {
|
|
34
34
|
"import": "./dist/publish.js",
|
|
35
|
-
"types": "./dist/types/
|
|
35
|
+
"types": "./dist/types/publish.d.ts"
|
|
36
36
|
},
|
|
37
37
|
"./wrapper": {
|
|
38
38
|
"import": "./dist/wrapper.js",
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
export interface MediaInfo {
|
|
2
|
-
media_id: string;
|
|
3
|
-
url: string;
|
|
4
|
-
updated_at?: number;
|
|
5
|
-
}
|
|
6
|
-
declare class UploadCacheStore {
|
|
7
|
-
private cache;
|
|
8
|
-
private initPromise;
|
|
9
|
-
constructor();
|
|
10
|
-
private load;
|
|
11
|
-
private save;
|
|
12
|
-
get(md5: string): Promise<MediaInfo | undefined>;
|
|
13
|
-
set(md5: string, mediaId: string, url: string): Promise<void>;
|
|
14
|
-
}
|
|
15
|
-
export declare const uploadCacheStore: UploadCacheStore;
|
|
16
|
-
export {};
|