md2any 1.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.md +136 -0
- package/SKILL.md +80 -0
- package/bin/md2any.js +227 -0
- package/lib/actions.js +262 -0
- package/lib/converter.js +864 -0
- package/lib/extract.js +195 -0
- package/lib/llm.js +281 -0
- package/lib/social.js +264 -0
- package/lib/store.js +70 -0
- package/lib/themes.js +588 -0
- package/lib/zhihu.js +666 -0
- package/package.json +65 -0
- package/scripts/social_worker.js +774 -0
- package/scripts/xhs_screenshot.js +260 -0
package/lib/zhihu.js
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 知乎发布模块
|
|
5
|
+
*
|
|
6
|
+
* Cookie 安全说明:
|
|
7
|
+
* - Cookie 通过调用方传入的 storageGet/storageSet 存取(对应 VS Code globalState)
|
|
8
|
+
* - 不写入任何磁盘文件,不会被 git 追踪
|
|
9
|
+
* - storageKey 固定为 'zhihu.cookieString'
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const https = require('https');
|
|
13
|
+
const http = require('http');
|
|
14
|
+
const { URL } = require('url');
|
|
15
|
+
const QRCode = require('qrcode');
|
|
16
|
+
|
|
17
|
+
// ─── 常量 ───────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const STORAGE_KEY = 'zhihu.cookieString';
|
|
20
|
+
|
|
21
|
+
const BASE_HEADERS = {
|
|
22
|
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
23
|
+
'Accept': 'application/json, text/plain, */*',
|
|
24
|
+
'Accept-Language': 'zh-CN,zh;q=0.9',
|
|
25
|
+
'Referer': 'https://www.zhihu.com/',
|
|
26
|
+
'Origin': 'https://www.zhihu.com',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// ─── 请求工具 ────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 发送 HTTP(S) 请求
|
|
33
|
+
* @param {{ method, hostname, path, headers, body, timeout }} opts
|
|
34
|
+
* @returns {Promise<{ status, headers, body }>}
|
|
35
|
+
*/
|
|
36
|
+
function request(opts) {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
// _rawBody 优先(Buffer,用于 multipart);否则用 body 字符串
|
|
39
|
+
const rawBody = opts._rawBody instanceof Buffer ? opts._rawBody : null;
|
|
40
|
+
const strBody = rawBody ? null : (opts.body ? (typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body)) : null);
|
|
41
|
+
|
|
42
|
+
const reqOpts = {
|
|
43
|
+
hostname: opts.hostname,
|
|
44
|
+
path: opts.path,
|
|
45
|
+
method: opts.method || 'GET',
|
|
46
|
+
headers: Object.assign({}, BASE_HEADERS, opts.headers || {}),
|
|
47
|
+
};
|
|
48
|
+
// Content-Length 未由调用方指定时自动填充
|
|
49
|
+
if (rawBody && !reqOpts.headers['Content-Length']) {
|
|
50
|
+
reqOpts.headers['Content-Length'] = rawBody.length;
|
|
51
|
+
} else if (strBody && !reqOpts.headers['Content-Length']) {
|
|
52
|
+
reqOpts.headers['Content-Length'] = Buffer.byteLength(strBody, 'utf8');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const req = https.request(reqOpts, (res) => {
|
|
56
|
+
const chunks = [];
|
|
57
|
+
res.on('data', c => chunks.push(c));
|
|
58
|
+
res.on('end', () => {
|
|
59
|
+
resolve({
|
|
60
|
+
status: res.statusCode,
|
|
61
|
+
headers: res.headers,
|
|
62
|
+
body: Buffer.concat(chunks).toString('utf8'),
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
req.on('error', reject);
|
|
68
|
+
req.setTimeout(opts.timeout || 30000, () => {
|
|
69
|
+
req.destroy();
|
|
70
|
+
reject(new Error('请求超时'));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (rawBody) req.write(rawBody);
|
|
74
|
+
else if (strBody) req.write(strBody, 'utf8');
|
|
75
|
+
req.end();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 从 cookie 字符串里提取某个 key 的值
|
|
81
|
+
*/
|
|
82
|
+
function getCookieValue(cookieStr, key) {
|
|
83
|
+
if (!cookieStr) return '';
|
|
84
|
+
const m = cookieStr.match(new RegExp('(?:^|;\\s*)' + key + '=([^;]+)'));
|
|
85
|
+
return m ? decodeURIComponent(m[1]) : '';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 合并 Set-Cookie 响应头到已有 cookie 字符串
|
|
90
|
+
* 已存在的 key 会被覆盖,新 key 会追加
|
|
91
|
+
*/
|
|
92
|
+
function mergeCookies(existing, setCookieHeaders) {
|
|
93
|
+
const map = new Map();
|
|
94
|
+
// 先加载已有 cookie
|
|
95
|
+
(existing || '').split(/;\s*/).forEach(pair => {
|
|
96
|
+
const idx = pair.indexOf('=');
|
|
97
|
+
if (idx > 0) {
|
|
98
|
+
const k = pair.slice(0, idx).trim();
|
|
99
|
+
const v = pair.slice(idx + 1).trim();
|
|
100
|
+
if (k) map.set(k, v);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
// 合并新 cookie(只取 name=value 部分,忽略 path/domain/expires 等属性)
|
|
104
|
+
const headers = Array.isArray(setCookieHeaders) ? setCookieHeaders : (setCookieHeaders ? [setCookieHeaders] : []);
|
|
105
|
+
headers.forEach(hdr => {
|
|
106
|
+
const part = hdr.split(';')[0].trim();
|
|
107
|
+
const idx = part.indexOf('=');
|
|
108
|
+
if (idx > 0) {
|
|
109
|
+
const k = part.slice(0, idx).trim();
|
|
110
|
+
const v = part.slice(idx + 1).trim();
|
|
111
|
+
if (k) map.set(k, v);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
return Array.from(map.entries()).map(([k, v]) => `${k}=${v}`).join('; ');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── Cookie 存储(委托给调用方) ─────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @typedef {Object} CookieStorage
|
|
121
|
+
* @property {() => string} get
|
|
122
|
+
* @property {(v: string) => void} set
|
|
123
|
+
* @property {() => void} clear
|
|
124
|
+
*/
|
|
125
|
+
|
|
126
|
+
// ─── 登录检测 ────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 检查 cookie 是否包含有效的 z_c0 令牌(不发网络请求)
|
|
130
|
+
*/
|
|
131
|
+
function isLoggedIn(cookieStr) {
|
|
132
|
+
return !!getCookieValue(cookieStr, 'z_c0');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 通过 /api/v4/me 验证 cookie 是否仍然有效
|
|
137
|
+
*/
|
|
138
|
+
async function verifyLogin(cookieStr) {
|
|
139
|
+
try {
|
|
140
|
+
const res = await request({
|
|
141
|
+
hostname: 'www.zhihu.com',
|
|
142
|
+
path: '/api/v4/me',
|
|
143
|
+
method: 'GET',
|
|
144
|
+
headers: { Cookie: cookieStr },
|
|
145
|
+
});
|
|
146
|
+
if (res.status === 200) {
|
|
147
|
+
const data = JSON.parse(res.body);
|
|
148
|
+
return { valid: true, name: data.name || '', headline: data.headline || '' };
|
|
149
|
+
}
|
|
150
|
+
return { valid: false };
|
|
151
|
+
} catch (_) {
|
|
152
|
+
return { valid: false };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ─── 扫码登录 ────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 第一步:获取 UDID(知乎扫码需要先建立 session)
|
|
160
|
+
*/
|
|
161
|
+
async function fetchUdid() {
|
|
162
|
+
const res = await request({
|
|
163
|
+
hostname: 'www.zhihu.com',
|
|
164
|
+
path: '/udid',
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': '0' },
|
|
167
|
+
});
|
|
168
|
+
// 收集 set-cookie
|
|
169
|
+
return {
|
|
170
|
+
cookieStr: mergeCookies('', res.headers['set-cookie']),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 第二步:创建扫码 session,在 Node 端用 qrcode 库生成二维码图片(base64 data URL)
|
|
176
|
+
* 知乎接口现在返回 { token, link, expires_at },link 是扫码目标 URL,无单独图片
|
|
177
|
+
*/
|
|
178
|
+
async function createQrSession(cookieStr) {
|
|
179
|
+
const xsrf = getCookieValue(cookieStr, '_xsrf');
|
|
180
|
+
const res = await request({
|
|
181
|
+
hostname: 'www.zhihu.com',
|
|
182
|
+
path: '/api/v3/account/api/login/qrcode',
|
|
183
|
+
method: 'POST',
|
|
184
|
+
headers: {
|
|
185
|
+
'Content-Type': 'application/json',
|
|
186
|
+
'x-xsrftoken': xsrf,
|
|
187
|
+
Cookie: cookieStr,
|
|
188
|
+
'x-requested-with': 'fetch',
|
|
189
|
+
},
|
|
190
|
+
body: '{}',
|
|
191
|
+
});
|
|
192
|
+
if (res.status !== 200) {
|
|
193
|
+
throw new Error(`创建扫码 session 失败(HTTP ${res.status}):${res.body.slice(0, 200)}`);
|
|
194
|
+
}
|
|
195
|
+
const newCookie = mergeCookies(cookieStr, res.headers['set-cookie']);
|
|
196
|
+
const data = JSON.parse(res.body);
|
|
197
|
+
const token = data.token;
|
|
198
|
+
const link = data.link;
|
|
199
|
+
if (!token || !link) {
|
|
200
|
+
throw new Error(`接口响应缺少 token/link 字段:${res.body.slice(0, 200)}`);
|
|
201
|
+
}
|
|
202
|
+
// 在 Node 端生成二维码图片(data URL),绕过 webview CSP 限制
|
|
203
|
+
const imageDataUrl = await QRCode.toDataURL(link, { width: 240, margin: 2 });
|
|
204
|
+
return { token, link, imageDataUrl, cookieStr: newCookie };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 第三步:轮询扫码状态
|
|
209
|
+
* 返回 { status: 'waiting'|'scanned'|'confirmed'|'expired'|'error', cookieStr, userId }
|
|
210
|
+
*/
|
|
211
|
+
async function pollQrStatus(token, cookieStr) {
|
|
212
|
+
const xsrf = getCookieValue(cookieStr, '_xsrf');
|
|
213
|
+
const res = await request({
|
|
214
|
+
hostname: 'www.zhihu.com',
|
|
215
|
+
path: `/api/v3/account/api/login/qrcode/${token}/scan_info`,
|
|
216
|
+
method: 'GET',
|
|
217
|
+
headers: {
|
|
218
|
+
'x-xsrftoken': xsrf,
|
|
219
|
+
Cookie: cookieStr,
|
|
220
|
+
'x-requested-with': 'fetch',
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
const newCookie = mergeCookies(cookieStr, res.headers['set-cookie']);
|
|
224
|
+
|
|
225
|
+
if (res.status !== 200) {
|
|
226
|
+
return { status: 'error', cookieStr: newCookie };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const data = JSON.parse(res.body);
|
|
230
|
+
// status: 0 = 等待, 1 = 已扫码/已确认
|
|
231
|
+
if (data.status === 1 && data.user_id) {
|
|
232
|
+
// 已确认登录,验证 z_c0
|
|
233
|
+
if (getCookieValue(newCookie, 'z_c0')) {
|
|
234
|
+
return { status: 'confirmed', cookieStr: newCookie, userId: data.user_id };
|
|
235
|
+
}
|
|
236
|
+
return { status: 'scanned', cookieStr: newCookie };
|
|
237
|
+
}
|
|
238
|
+
if (data.status === -1 || data.expired) {
|
|
239
|
+
return { status: 'expired', cookieStr: newCookie };
|
|
240
|
+
}
|
|
241
|
+
return { status: 'waiting', cookieStr: newCookie };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ─── 图片上传 ────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 上传一张图片(base64 data URL)到知乎图床
|
|
248
|
+
* 两步流程(与 VSCode-Zhihu 相同):
|
|
249
|
+
* 1. POST https://api.zhihu.com/images 预取上传 token(body 包含 md5 hash)
|
|
250
|
+
* 2a. 若返回 upload_token → 用 ali-oss SDK 上传到阿里云 OSS
|
|
251
|
+
* 2b. 若无 upload_token → 图片已存在,直接用 hash 构造 CDN URL
|
|
252
|
+
* @param {string} dataUrl data:image/png;base64,...
|
|
253
|
+
* @param {string} cookieStr
|
|
254
|
+
* @returns {string} 知乎 CDN 图片 URL
|
|
255
|
+
*/
|
|
256
|
+
async function uploadImage(dataUrl, cookieStr) {
|
|
257
|
+
const md5 = require('md5');
|
|
258
|
+
const OSS = require('ali-oss');
|
|
259
|
+
|
|
260
|
+
const m = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
|
261
|
+
if (!m) throw new Error('无效的图片 dataUrl');
|
|
262
|
+
const mime = m[1];
|
|
263
|
+
const buffer = Buffer.from(m[2], 'base64');
|
|
264
|
+
const ext = '.' + (mime.split('/')[1] || 'png').replace('jpeg', 'jpg');
|
|
265
|
+
const hash = md5(buffer);
|
|
266
|
+
|
|
267
|
+
const xsrf = getCookieValue(cookieStr, '_xsrf');
|
|
268
|
+
|
|
269
|
+
// Step 1: 预取上传 token
|
|
270
|
+
const prefetchRes = await request({
|
|
271
|
+
hostname: 'api.zhihu.com',
|
|
272
|
+
path: '/images',
|
|
273
|
+
method: 'POST',
|
|
274
|
+
headers: {
|
|
275
|
+
'Content-Type': 'application/json',
|
|
276
|
+
Cookie: cookieStr,
|
|
277
|
+
'x-xsrftoken': xsrf,
|
|
278
|
+
'Referer': 'https://zhuanlan.zhihu.com/',
|
|
279
|
+
'Origin': 'https://zhuanlan.zhihu.com',
|
|
280
|
+
'x-requested-with': 'fetch',
|
|
281
|
+
},
|
|
282
|
+
body: JSON.stringify({ image_hash: hash, source: 'article' }),
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
if (prefetchRes.status === 401) throw new Error('知乎未登录,请重新登录');
|
|
286
|
+
if (prefetchRes.status !== 200) {
|
|
287
|
+
throw new Error(`图片预取 token 失败(HTTP ${prefetchRes.status}):${prefetchRes.body.slice(0, 200)}`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const prefetchData = JSON.parse(prefetchRes.body);
|
|
291
|
+
const upload_file = prefetchData.upload_file;
|
|
292
|
+
const upload_token = prefetchData.upload_token;
|
|
293
|
+
|
|
294
|
+
// Step 2a: 有 upload_token,走 OSS 上传
|
|
295
|
+
if (upload_token) {
|
|
296
|
+
const client = new OSS({
|
|
297
|
+
bucket: 'zhihu-pics',
|
|
298
|
+
endpoint: 'zhihu-pics-upload.zhimg.com',
|
|
299
|
+
region: 'oss-cn-hangzhou',
|
|
300
|
+
accessKeyId: upload_token.access_id,
|
|
301
|
+
accessKeySecret: upload_token.access_key,
|
|
302
|
+
stsToken: upload_token.access_token,
|
|
303
|
+
});
|
|
304
|
+
await client.put(upload_file.object_key, buffer);
|
|
305
|
+
}
|
|
306
|
+
// Step 2b: 无 upload_token,图片已存在于知乎图床,直接返回 URL
|
|
307
|
+
|
|
308
|
+
return `https://pic4.zhimg.com/80/v2-${hash}${ext}`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ─── 发布文章 ────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* 上传 HTML 中所有 base64 图片到知乎图床,替换 src 为 CDN URL
|
|
315
|
+
* @param {string} htmlContent
|
|
316
|
+
* @param {string} cookieStr
|
|
317
|
+
* @param {(done:number,total:number)=>void} onProgress
|
|
318
|
+
* @returns {string} 替换后的 HTML
|
|
319
|
+
*/
|
|
320
|
+
async function uploadImagesInHtml(htmlContent, cookieStr, onProgress) {
|
|
321
|
+
const cheerio = require('cheerio');
|
|
322
|
+
const $ = cheerio.load(`<div id="root">${htmlContent}</div>`, { decodeEntities: false });
|
|
323
|
+
|
|
324
|
+
const imgs = [];
|
|
325
|
+
$('img').each((_, el) => {
|
|
326
|
+
const src = $(el).attr('src') || '';
|
|
327
|
+
if (src.startsWith('data:image/')) imgs.push(el);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
let done = 0;
|
|
331
|
+
const errors = [];
|
|
332
|
+
for (const el of imgs) {
|
|
333
|
+
const src = $(el).attr('src');
|
|
334
|
+
try {
|
|
335
|
+
const cdnUrl = await uploadImage(src, cookieStr);
|
|
336
|
+
$(el).attr('src', cdnUrl);
|
|
337
|
+
} catch (err) {
|
|
338
|
+
errors.push(err.message);
|
|
339
|
+
}
|
|
340
|
+
done++;
|
|
341
|
+
if (onProgress) onProgress(done, imgs.length, errors.length);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return { html: $('#root').html() || htmlContent, total: imgs.length, failed: errors.length, errors };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* 将图片标签规范化为知乎可识别的格式:
|
|
349
|
+
* - 去掉 <figure> 包裹,改为 <p> 包裹
|
|
350
|
+
* - 去掉图片 style 属性(知乎会自行处理图片样式)
|
|
351
|
+
* - 保留 figcaption 作为图片后的说明文字
|
|
352
|
+
*/
|
|
353
|
+
function normalizeImagesForZhihu(htmlContent) {
|
|
354
|
+
const cheerio = require('cheerio');
|
|
355
|
+
const $ = cheerio.load(`<div id="root">${htmlContent}</div>`, { decodeEntities: false });
|
|
356
|
+
|
|
357
|
+
$('figure').each((_, fig) => {
|
|
358
|
+
const $fig = $(fig);
|
|
359
|
+
const $img = $fig.find('img').first();
|
|
360
|
+
const $caption = $fig.find('figcaption').first();
|
|
361
|
+
if (!$img.length) return;
|
|
362
|
+
|
|
363
|
+
// 去掉图片内联 style,只保留 src 和 alt
|
|
364
|
+
$img.removeAttr('style');
|
|
365
|
+
$img.attr('data-size', 'normal');
|
|
366
|
+
$img.attr('data-watermark', 'original');
|
|
367
|
+
|
|
368
|
+
const parts = [];
|
|
369
|
+
parts.push(`<p>${$.html($img)}</p>`);
|
|
370
|
+
if ($caption.length) {
|
|
371
|
+
parts.push(`<p style="text-align:center;color:#999;font-size:14px;">${$caption.text()}</p>`);
|
|
372
|
+
}
|
|
373
|
+
$fig.replaceWith(parts.join(''));
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// 独立 img 标签(不在 figure 里)也去掉 style
|
|
377
|
+
$('p > img, div > img').each((_, el) => {
|
|
378
|
+
$(el).removeAttr('style');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
return $('#root').html() || htmlContent;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* 将代码块转为知乎原生格式:<pre lang="python">纯文本</pre>
|
|
386
|
+
* 知乎只识别 lang 属性,不支持 CSS class / 内联样式 / span 高亮
|
|
387
|
+
*/
|
|
388
|
+
function normalizeCodeBlocks(htmlContent) {
|
|
389
|
+
const cheerio = require('cheerio');
|
|
390
|
+
const $ = cheerio.load(`<div id="root">${htmlContent}</div>`, { decodeEntities: false });
|
|
391
|
+
|
|
392
|
+
$('pre').each((_, pre) => {
|
|
393
|
+
const $pre = $(pre);
|
|
394
|
+
const $code = $pre.find('code').first();
|
|
395
|
+
const cls = $code.attr('class') || '';
|
|
396
|
+
const text = $code.text();
|
|
397
|
+
|
|
398
|
+
// 提取语言。highlight.js 生成的 class 是 "hljs python" 这种形式,
|
|
399
|
+
// 之前的正则先试 language-xxx(匹配不上),再退到 \b(\w+)\b —— 结果抓到了 "hljs"。
|
|
400
|
+
// 知乎收到 lang="hljs" 这个不认识的语言,既不高亮、也可能不当代码块处理(换行因此全丢)。
|
|
401
|
+
const lang = extractCodeLang(cls);
|
|
402
|
+
|
|
403
|
+
const langAttr = lang ? ` lang="${lang}"` : '';
|
|
404
|
+
$pre.replaceWith(`<pre${langAttr}>${escapeHtmlEntities(text)}</pre>`);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
return $('#root').html() || htmlContent;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function escapeHtmlEntities(str) {
|
|
411
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* 生成【发布用】的干净 HTML。
|
|
416
|
+
*
|
|
417
|
+
* 之前的 bug:发布路径直接把 buildZhihuCopyHtml() 的产物(给剪贴板用的、每个标签都带
|
|
418
|
+
* 内联样式的完整文档,动辄几百 KB)丢给知乎接口。知乎的内容过滤器会把这些样式洗掉/洗坏,
|
|
419
|
+
* 结果就是代码折叠、公式丢失、排版稀烂。
|
|
420
|
+
*
|
|
421
|
+
* 发布要的是**干净的语义化 HTML**:
|
|
422
|
+
* - 公式 → <img eeimg="1" src="https://www.zhihu.com/equation?tex=...">
|
|
423
|
+
* - 代码 → <pre lang="python">纯文本</pre>
|
|
424
|
+
* - 图片 → <p><img src="..."></p>(src 后续会被替换成知乎图床 URL)
|
|
425
|
+
* - 去掉所有 style / class
|
|
426
|
+
*/
|
|
427
|
+
function buildPublishHtml(bodyHtml) {
|
|
428
|
+
const cheerio = require('cheerio');
|
|
429
|
+
const $ = cheerio.load(`<div id="root">${bodyHtml}</div>`, { decodeEntities: false });
|
|
430
|
+
|
|
431
|
+
// 1) 公式 → 知乎 eeimg 图片
|
|
432
|
+
$('[data-math]').each((_, el) => {
|
|
433
|
+
const $el = $(el);
|
|
434
|
+
const latex = $el.attr('data-math') || '';
|
|
435
|
+
const isDisplay = $el.attr('data-display') === 'true';
|
|
436
|
+
const src = `https://www.zhihu.com/equation?tex=${encodeURIComponent(latex)}`;
|
|
437
|
+
const alt = escapeHtmlEntities(latex);
|
|
438
|
+
$el.replaceWith(isDisplay
|
|
439
|
+
? `<p><img eeimg="1" src="${src}" alt="\\\\${alt}"></p>` // 块公式:alt 以 \\ 开头
|
|
440
|
+
: `<img eeimg="1" src="${src}" alt="${alt}">`);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// 2) 代码块 → <pre lang="python">
|
|
444
|
+
$('pre').each((_, pre) => {
|
|
445
|
+
const $pre = $(pre);
|
|
446
|
+
const $code = $pre.find('code').first();
|
|
447
|
+
const lang = extractCodeLang($code.attr('class') || '');
|
|
448
|
+
const text = $code.length ? $code.text() : $pre.text();
|
|
449
|
+
$pre.replaceWith(`<pre${lang ? ` lang="${lang}"` : ''}>${escapeHtmlEntities(text)}</pre>`);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// 3) figure → <p><img></p> + 说明文字
|
|
453
|
+
$('figure').each((_, fig) => {
|
|
454
|
+
const $fig = $(fig);
|
|
455
|
+
const $img = $fig.find('img').first();
|
|
456
|
+
const $cap = $fig.find('figcaption').first();
|
|
457
|
+
if (!$img.length) { $fig.remove(); return; }
|
|
458
|
+
const parts = [`<p><img src="${$img.attr('src')}" alt="${escapeHtmlEntities($img.attr('alt') || '')}"></p>`];
|
|
459
|
+
if ($cap.length) parts.push(`<p>${escapeHtmlEntities($cap.text())}</p>`);
|
|
460
|
+
$fig.replaceWith(parts.join(''));
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// 4) 洗掉所有样式/类(知乎不认,留着只会干扰它的解析)
|
|
464
|
+
// 注意:只洗 root 的后代,别把 root 自己的 id 也剥了(否则下面取不到内容)
|
|
465
|
+
const $root = $('#root');
|
|
466
|
+
$root.find('*').each((_, el) => {
|
|
467
|
+
$(el).removeAttr('style').removeAttr('class').removeAttr('id');
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
return $root.html() || '';
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** 知乎代码块支持的语言(用它们的官方标识;不在表里的一律降级为 text) */
|
|
474
|
+
const ZHIHU_LANGS = new Set([
|
|
475
|
+
'text', 'bash', 'shell', 'c', 'cpp', 'csharp', 'css', 'go', 'haskell', 'html', 'java',
|
|
476
|
+
'javascript', 'json', 'julia', 'kotlin', 'lua', 'makefile', 'markdown', 'matlab', 'nginx',
|
|
477
|
+
'objectivec', 'perl', 'php', 'python', 'r', 'ruby', 'rust', 'scala', 'sql', 'swift',
|
|
478
|
+
'typescript', 'verilog', 'vim', 'xml', 'yaml', 'dockerfile', 'diff', 'ini', 'toml',
|
|
479
|
+
]);
|
|
480
|
+
|
|
481
|
+
/** 常见别名 → 知乎标识 */
|
|
482
|
+
const LANG_ALIAS = {
|
|
483
|
+
js: 'javascript', ts: 'typescript', py: 'python', sh: 'bash', zsh: 'bash',
|
|
484
|
+
'c++': 'cpp', 'c#': 'csharp', yml: 'yaml', md: 'markdown', golang: 'go',
|
|
485
|
+
jsx: 'javascript', tsx: 'typescript', plaintext: 'text', txt: 'text',
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* 从 code 的 class 里提取语言。
|
|
490
|
+
* class 可能是 "hljs python" / "language-python" / "hljs language-py"。
|
|
491
|
+
* 必须排除 "hljs" 这类非语言 token。
|
|
492
|
+
*/
|
|
493
|
+
function extractCodeLang(cls) {
|
|
494
|
+
const tokens = String(cls || '').split(/\s+/).filter(Boolean);
|
|
495
|
+
let raw = '';
|
|
496
|
+
for (const t of tokens) {
|
|
497
|
+
const m = t.match(/^language-(.+)$/);
|
|
498
|
+
if (m) { raw = m[1]; break; }
|
|
499
|
+
}
|
|
500
|
+
if (!raw) {
|
|
501
|
+
// 退而求其次:取第一个不是 hljs / mac-code 之类的 token
|
|
502
|
+
raw = tokens.find(t => !['hljs', 'mac-code', 'code', 'highlight'].includes(t.toLowerCase())) || '';
|
|
503
|
+
}
|
|
504
|
+
raw = raw.toLowerCase();
|
|
505
|
+
raw = LANG_ALIAS[raw] || raw;
|
|
506
|
+
return ZHIHU_LANGS.has(raw) ? raw : (raw ? 'text' : '');
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* 保存草稿(不发布):新建或更新草稿,返回知乎编辑器预览链接
|
|
511
|
+
* @returns {{ articleId, editUrl }}
|
|
512
|
+
*/
|
|
513
|
+
async function saveAsDraft({ articleId, title, htmlContent, cookieStr }) {
|
|
514
|
+
const xsrf = getCookieValue(cookieStr, '_xsrf');
|
|
515
|
+
const commonHeaders = {
|
|
516
|
+
'Content-Type': 'application/json',
|
|
517
|
+
'x-xsrftoken': xsrf,
|
|
518
|
+
Cookie: cookieStr,
|
|
519
|
+
'x-requested-with': 'fetch',
|
|
520
|
+
'sec-fetch-mode': 'cors',
|
|
521
|
+
'sec-fetch-dest': 'empty',
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
let id = articleId;
|
|
525
|
+
if (!id) {
|
|
526
|
+
const createRes = await request({
|
|
527
|
+
hostname: 'zhuanlan.zhihu.com',
|
|
528
|
+
path: '/api/articles/drafts',
|
|
529
|
+
method: 'POST',
|
|
530
|
+
headers: commonHeaders,
|
|
531
|
+
body: JSON.stringify({ delta_time: 0 }),
|
|
532
|
+
});
|
|
533
|
+
if (createRes.status !== 200 && createRes.status !== 201) {
|
|
534
|
+
let msg = `创建草稿失败(HTTP ${createRes.status})`;
|
|
535
|
+
try { msg += ':' + (JSON.parse(createRes.body).message || createRes.body.slice(0, 200)); } catch (_) {}
|
|
536
|
+
throw new Error(msg);
|
|
537
|
+
}
|
|
538
|
+
id = JSON.parse(createRes.body).id;
|
|
539
|
+
if (!id) throw new Error('创建草稿失败:未返回 article id');
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const patchRes = await request({
|
|
543
|
+
hostname: 'zhuanlan.zhihu.com',
|
|
544
|
+
path: `/api/articles/${id}/draft`,
|
|
545
|
+
method: 'PATCH',
|
|
546
|
+
headers: commonHeaders,
|
|
547
|
+
body: JSON.stringify({ title, content: htmlContent, delta_time: 0 }),
|
|
548
|
+
});
|
|
549
|
+
if (patchRes.status !== 200) {
|
|
550
|
+
let msg = `保存草稿失败(HTTP ${patchRes.status})`;
|
|
551
|
+
try { msg += ':' + (JSON.parse(patchRes.body).message || patchRes.body.slice(0, 200)); } catch (_) {}
|
|
552
|
+
throw new Error(msg);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// 草稿箱地址(编辑器 /write 会过滤 HTML,草稿箱直接渲染原始内容)
|
|
556
|
+
return { articleId: id, editUrl: `https://zhuanlan.zhihu.com/drafts` };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* 新建文章:创建草稿 → 更新内容 → 发布
|
|
561
|
+
* @returns {{ articleId, url }}
|
|
562
|
+
*/
|
|
563
|
+
async function createAndPublishArticle({ title, htmlContent, cookieStr }) {
|
|
564
|
+
const xsrf = getCookieValue(cookieStr, '_xsrf');
|
|
565
|
+
|
|
566
|
+
const commonHeaders = {
|
|
567
|
+
'Content-Type': 'application/json',
|
|
568
|
+
'x-xsrftoken': xsrf,
|
|
569
|
+
Cookie: cookieStr,
|
|
570
|
+
'x-requested-with': 'fetch',
|
|
571
|
+
'sec-fetch-mode': 'cors',
|
|
572
|
+
'sec-fetch-dest': 'empty',
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
const createRes = await request({
|
|
576
|
+
hostname: 'zhuanlan.zhihu.com',
|
|
577
|
+
path: '/api/articles/drafts',
|
|
578
|
+
method: 'POST',
|
|
579
|
+
headers: commonHeaders,
|
|
580
|
+
body: JSON.stringify({ delta_time: 0 }),
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
if (createRes.status !== 200 && createRes.status !== 201) {
|
|
584
|
+
let msg = `创建草稿失败(HTTP ${createRes.status})`;
|
|
585
|
+
try { msg += ':' + (JSON.parse(createRes.body).message || createRes.body.slice(0, 200)); } catch (_) {}
|
|
586
|
+
throw new Error(msg);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const draft = JSON.parse(createRes.body);
|
|
590
|
+
const articleId = draft.id;
|
|
591
|
+
if (!articleId) throw new Error('创建草稿失败:未返回 article id');
|
|
592
|
+
|
|
593
|
+
await _patchAndPublish({ articleId, title, htmlContent, commonHeaders, cookieStr });
|
|
594
|
+
return { articleId, url: `https://zhuanlan.zhihu.com/p/${articleId}` };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* 更新已有文章:更新草稿内容 → 重新发布
|
|
599
|
+
* @returns {{ articleId, url }}
|
|
600
|
+
*/
|
|
601
|
+
async function updateAndPublishArticle({ articleId, title, htmlContent, cookieStr }) {
|
|
602
|
+
const xsrf = getCookieValue(cookieStr, '_xsrf');
|
|
603
|
+
const commonHeaders = {
|
|
604
|
+
'Content-Type': 'application/json',
|
|
605
|
+
'x-xsrftoken': xsrf,
|
|
606
|
+
Cookie: cookieStr,
|
|
607
|
+
'x-requested-with': 'fetch',
|
|
608
|
+
'sec-fetch-mode': 'cors',
|
|
609
|
+
'sec-fetch-dest': 'empty',
|
|
610
|
+
};
|
|
611
|
+
await _patchAndPublish({ articleId, title, htmlContent, commonHeaders, cookieStr });
|
|
612
|
+
return { articleId, url: `https://zhuanlan.zhihu.com/p/${articleId}` };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async function _patchAndPublish({ articleId, title, htmlContent, commonHeaders }) {
|
|
616
|
+
const patchRes = await request({
|
|
617
|
+
hostname: 'zhuanlan.zhihu.com',
|
|
618
|
+
path: `/api/articles/${articleId}/draft`,
|
|
619
|
+
method: 'PATCH',
|
|
620
|
+
headers: commonHeaders,
|
|
621
|
+
body: JSON.stringify({ title, content: htmlContent, delta_time: 0 }),
|
|
622
|
+
});
|
|
623
|
+
if (patchRes.status !== 200) {
|
|
624
|
+
let msg = `更新草稿失败(HTTP ${patchRes.status})`;
|
|
625
|
+
try { msg += ':' + (JSON.parse(patchRes.body).message || patchRes.body.slice(0, 200)); } catch (_) {}
|
|
626
|
+
throw new Error(msg);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const publishRes = await request({
|
|
630
|
+
hostname: 'zhuanlan.zhihu.com',
|
|
631
|
+
path: `/api/articles/${articleId}/publish`,
|
|
632
|
+
method: 'PUT',
|
|
633
|
+
headers: commonHeaders,
|
|
634
|
+
body: JSON.stringify({ disclaimer_type: 'none', disclaimer_status: 'close' }),
|
|
635
|
+
});
|
|
636
|
+
if (publishRes.status !== 200) {
|
|
637
|
+
let msg = `发布失败(HTTP ${publishRes.status})`;
|
|
638
|
+
try { msg += ':' + (JSON.parse(publishRes.body).message || publishRes.body.slice(0, 200)); } catch (_) {}
|
|
639
|
+
throw new Error(msg);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// 保留旧名兼容(内部调用)
|
|
644
|
+
async function publishArticle({ title, htmlContent, cookieStr }) {
|
|
645
|
+
return createAndPublishArticle({ title, htmlContent, cookieStr });
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
module.exports = {
|
|
649
|
+
STORAGE_KEY,
|
|
650
|
+
buildPublishHtml,
|
|
651
|
+
extractCodeLang,
|
|
652
|
+
isLoggedIn,
|
|
653
|
+
verifyLogin,
|
|
654
|
+
fetchUdid,
|
|
655
|
+
createQrSession,
|
|
656
|
+
pollQrStatus,
|
|
657
|
+
uploadImage,
|
|
658
|
+
uploadImagesInHtml,
|
|
659
|
+
normalizeCodeBlocks,
|
|
660
|
+
normalizeImagesForZhihu,
|
|
661
|
+
saveAsDraft,
|
|
662
|
+
createAndPublishArticle,
|
|
663
|
+
updateAndPublishArticle,
|
|
664
|
+
publishArticle,
|
|
665
|
+
mergeCookies,
|
|
666
|
+
};
|