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/actions.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CLI 的核心动作。**全部复用插件已有的 lib/ 和 scripts/**,不重写任何逻辑:
|
|
5
|
+
* lib/converter.js Markdown → HTML
|
|
6
|
+
* lib/zhihu.js 知乎干净发布 HTML(<pre lang> + eeimg 公式)
|
|
7
|
+
* lib/llm.js LLM 文案生成(OpenAI 兼容)
|
|
8
|
+
* lib/extract.js 本地关键词提取(无需 API Key)
|
|
9
|
+
* lib/social.js Cookie 管理 + Playwright 发布调度
|
|
10
|
+
* scripts/xhs_screenshot.js 长图截图
|
|
11
|
+
* scripts/social_worker.js 发布 worker
|
|
12
|
+
*
|
|
13
|
+
* 两种布局都要能跑:
|
|
14
|
+
* ① 仓库内开发: <repo>/cli/lib/actions.js → 源文件在 <repo>/lib、<repo>/scripts
|
|
15
|
+
* ② npm 安装后: <pkg>/lib/actions.js → 源文件已被 prepack vendored 到 <pkg>/lib、<pkg>/scripts
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const { spawn } = require('child_process');
|
|
22
|
+
|
|
23
|
+
// ROOT = 同时含有 lib/ 和 scripts/ 的那个目录
|
|
24
|
+
const PKG_ROOT = path.resolve(__dirname, '..'); // npm 安装后:包根
|
|
25
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..'); // 仓库内开发:仓库根
|
|
26
|
+
const ROOT = fs.existsSync(path.join(PKG_ROOT, 'scripts', 'social_worker.js')) ? PKG_ROOT : REPO_ROOT;
|
|
27
|
+
const LIB = path.join(ROOT, 'lib');
|
|
28
|
+
|
|
29
|
+
const converter = require(path.join(LIB, 'converter'));
|
|
30
|
+
const themes = require(path.join(LIB, 'themes'));
|
|
31
|
+
const zhihu = require(path.join(LIB, 'zhihu'));
|
|
32
|
+
const llm = require(path.join(LIB, 'llm'));
|
|
33
|
+
const extract = require(path.join(LIB, 'extract'));
|
|
34
|
+
const social = require(path.join(LIB, 'social'));
|
|
35
|
+
|
|
36
|
+
const store = require('./store');
|
|
37
|
+
|
|
38
|
+
const PLATFORMS = ['xiaohongshu', 'twitter', 'zhihu'];
|
|
39
|
+
const ALIAS = { xhs: 'xiaohongshu', x: 'twitter', tw: 'twitter', zh: 'zhihu' };
|
|
40
|
+
const normPlatform = (p) => ALIAS[p] || p;
|
|
41
|
+
|
|
42
|
+
// ─── 文章相关的路径约定(与插件保持一致)────────────────────
|
|
43
|
+
const baseOf = (md) => path.basename(md, path.extname(md));
|
|
44
|
+
const imagesDir = (md) => path.join(path.dirname(md), `${baseOf(md)}_xhs`);
|
|
45
|
+
const socialFile = (md) => path.join(path.dirname(md), `${baseOf(md)}_social.json`);
|
|
46
|
+
|
|
47
|
+
/** 已导出的长图:只取一套命名 + 数字自然排序(和插件同逻辑) */
|
|
48
|
+
function listImages(md) {
|
|
49
|
+
const dir = imagesDir(md);
|
|
50
|
+
if (!fs.existsSync(dir)) return [];
|
|
51
|
+
const files = fs.readdirSync(dir).filter(f => /\.(png|jpe?g)$/i.test(f));
|
|
52
|
+
if (!files.length) return [];
|
|
53
|
+
|
|
54
|
+
const fam = new Map();
|
|
55
|
+
for (const f of files) {
|
|
56
|
+
const key = f.replace(/\d+(\.\w+)$/, '$1').replace(/\.\w+$/, '');
|
|
57
|
+
if (!fam.has(key)) fam.set(key, []);
|
|
58
|
+
fam.get(key).push(f);
|
|
59
|
+
}
|
|
60
|
+
let chosen = null, t0 = -1;
|
|
61
|
+
for (const [, g] of fam) {
|
|
62
|
+
const t = Math.max(...g.map(f => { try { return fs.statSync(path.join(dir, f)).mtimeMs; } catch (_) { return 0; } }));
|
|
63
|
+
if (t > t0) { t0 = t; chosen = g; }
|
|
64
|
+
}
|
|
65
|
+
const num = (f) => { const m = f.match(/(\d+)(?=\.\w+$)/); return m ? +m[1] : 0; };
|
|
66
|
+
return chosen.sort((a, b) => num(a) - num(b)).map(f => path.join(dir, f));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** markdown 里按出现顺序的本地图片(知乎发布用) */
|
|
70
|
+
function listLocalImages(md) {
|
|
71
|
+
const raw = fs.readFileSync(md, 'utf8');
|
|
72
|
+
const dir = path.dirname(md);
|
|
73
|
+
const out = [];
|
|
74
|
+
const re = /!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
|
|
75
|
+
let m;
|
|
76
|
+
while ((m = re.exec(raw)) !== null) {
|
|
77
|
+
const s = m[1];
|
|
78
|
+
if (/^(https?:)?\/\//i.test(s) || s.startsWith('data:')) continue;
|
|
79
|
+
const abs = path.isAbsolute(s) ? s : path.resolve(dir, s);
|
|
80
|
+
if (fs.existsSync(abs)) out.push(abs);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readMeta(md) {
|
|
86
|
+
const matter = require('gray-matter');
|
|
87
|
+
const raw = fs.readFileSync(md, 'utf8');
|
|
88
|
+
const p = matter(raw);
|
|
89
|
+
const fm = p.data || {};
|
|
90
|
+
const title = fm.title || (p.content.match(/^#\s+(.+)$/m) || [])[1] || baseOf(md);
|
|
91
|
+
return { title: String(title).trim(), link: String(fm.permalink || fm.url || fm.link || '').trim() };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── 长图导出 ───────────────────────────────────────────────
|
|
95
|
+
function exportImages(md, { theme = 'zhihu', width = 1080, height = 1440, padding = 40, log = console.error } = {}) {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const { bodyHtml } = converter.renderMarkdown(md);
|
|
98
|
+
const html = converter.buildXhsRenderHtml(bodyHtml, path.dirname(md), themes.getTheme(theme));
|
|
99
|
+
const tmp = path.join(os.tmpdir(), `md2any_${Date.now()}.html`);
|
|
100
|
+
fs.writeFileSync(tmp, html, 'utf8');
|
|
101
|
+
|
|
102
|
+
const out = imagesDir(md);
|
|
103
|
+
const proc = spawn(process.execPath, [
|
|
104
|
+
path.join(ROOT, 'scripts', 'xhs_screenshot.js'), tmp, out,
|
|
105
|
+
'--width', String(width), '--height', String(height), '--padding', String(padding), '--bg', '#ffffff',
|
|
106
|
+
]);
|
|
107
|
+
let buf = '';
|
|
108
|
+
proc.stdout.on('data', d => {
|
|
109
|
+
buf += d.toString();
|
|
110
|
+
for (const line of d.toString().split('\n')) {
|
|
111
|
+
if (line.startsWith('INFO:')) log(' ' + line.slice(5));
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
proc.on('close', code => {
|
|
115
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
116
|
+
if (code === 2) { reject(new Error('未找到 Chromium,请先运行:md2any install-browser')); return; }
|
|
117
|
+
if (code !== 0) {
|
|
118
|
+
const err = buf.split('\n').find(l => l.startsWith('ERROR:')) || '截图失败';
|
|
119
|
+
reject(new Error(err.replace('ERROR:', '').trim()));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
resolve(listImages(md));
|
|
123
|
+
});
|
|
124
|
+
proc.on('error', reject);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── 文案:本地提取 / LLM 生成 ──────────────────────────────
|
|
129
|
+
function localCopy(md, platform) {
|
|
130
|
+
const { rawMarkdown } = converter.renderMarkdown(md);
|
|
131
|
+
return extract.extractCopy({ rawMarkdown, platform: platform === 'twitter' ? 'twitter' : 'xiaohongshu' });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function llmCopy(md, platform, { instruction, link } = {}) {
|
|
135
|
+
const cfg = store.getLlmConfig();
|
|
136
|
+
if (!cfg.baseUrl || !cfg.model) throw new Error('未配置 LLM,请先运行:md2any config llm --base-url ... --model ...');
|
|
137
|
+
const meta = readMeta(md);
|
|
138
|
+
const { rawMarkdown } = converter.renderMarkdown(md);
|
|
139
|
+
const context = llm.buildContext({
|
|
140
|
+
title: meta.title,
|
|
141
|
+
link: link || meta.link,
|
|
142
|
+
images: listImages(md),
|
|
143
|
+
rawMarkdown,
|
|
144
|
+
});
|
|
145
|
+
return llm.generateCopy({
|
|
146
|
+
platform: platform === 'twitter' ? 'twitter' : 'xiaohongshu',
|
|
147
|
+
instruction: instruction || llm.getDefaultInstruction(platform),
|
|
148
|
+
context,
|
|
149
|
+
config: cfg,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── 文案落盘(版本管理,与插件同一个 _social.json 格式)────
|
|
154
|
+
function loadStore(md) {
|
|
155
|
+
const raw = store.readJson(socialFile(md), {});
|
|
156
|
+
const out = { link: raw.link || '' };
|
|
157
|
+
for (const p of PLATFORMS) {
|
|
158
|
+
const v = raw[p];
|
|
159
|
+
out[p] = (v && Array.isArray(v.versions))
|
|
160
|
+
? { current: Math.min(v.current || 0, v.versions.length - 1), versions: v.versions }
|
|
161
|
+
: { current: -1, versions: [] };
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function addVersion(md, platform, content, source, link) {
|
|
167
|
+
const s = loadStore(md);
|
|
168
|
+
const list = s[platform].versions;
|
|
169
|
+
const id = list.length ? Math.max(...list.map(v => v.id || 0)) + 1 : 1;
|
|
170
|
+
list.push({ id, at: new Date().toISOString(), source: source || 'llm', content });
|
|
171
|
+
s[platform].current = list.length - 1;
|
|
172
|
+
if (link) s.link = link;
|
|
173
|
+
s.updatedAt = new Date().toISOString();
|
|
174
|
+
store.writeJson(socialFile(md), s);
|
|
175
|
+
return s;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function currentCopy(md, platform) {
|
|
179
|
+
const s = loadStore(md);
|
|
180
|
+
const p = s[platform];
|
|
181
|
+
return (p.current >= 0 && p.versions[p.current]) ? p.versions[p.current].content : null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ─── 登录 / 登录态 ──────────────────────────────────────────
|
|
185
|
+
function login(platform, { log = console.error } = {}) {
|
|
186
|
+
return social.login(platform, {
|
|
187
|
+
extensionPath: ROOT,
|
|
188
|
+
storage: store.cookieStorage(),
|
|
189
|
+
onProgress: (m) => log(' ' + m),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function status() {
|
|
194
|
+
const st = store.cookieStorage();
|
|
195
|
+
return PLATFORMS.map(p => {
|
|
196
|
+
// 知乎在插件里存的是 cookie 字符串(扫码登录),这里统一用浏览器 cookie 数组
|
|
197
|
+
const cookies = social.getCookies(p, st);
|
|
198
|
+
return Object.assign({ platform: p, name: social.META[p].name }, social.cookieStatus(p, cookies));
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── 发布 ───────────────────────────────────────────────────
|
|
203
|
+
async function publish(md, platform, { mode = 'prepare', headless = false, log = console.error } = {}) {
|
|
204
|
+
const st = store.cookieStorage();
|
|
205
|
+
const cookies = social.getCookies(platform, st);
|
|
206
|
+
if (!social.cookieStatus(platform, cookies).loggedIn) {
|
|
207
|
+
throw new Error(`${social.META[platform].name} 未登录,请先运行:md2any login ${platform}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const meta = readMeta(md);
|
|
211
|
+
let content, images;
|
|
212
|
+
|
|
213
|
+
if (platform === 'zhihu') {
|
|
214
|
+
const { bodyHtml } = converter.renderMarkdown(md);
|
|
215
|
+
content = { title: meta.title, html: zhihu.buildPublishHtml(bodyHtml) };
|
|
216
|
+
images = listLocalImages(md);
|
|
217
|
+
} else {
|
|
218
|
+
content = currentCopy(md, platform) || localCopy(md, platform);
|
|
219
|
+
images = listImages(md);
|
|
220
|
+
if (!images.length) {
|
|
221
|
+
log(' 未检测到长图,正在自动导出…');
|
|
222
|
+
images = await exportImages(md, { log });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return social.publish(platform, {
|
|
227
|
+
extensionPath: ROOT,
|
|
228
|
+
cookies, content, images,
|
|
229
|
+
link: meta.link,
|
|
230
|
+
mode, headless,
|
|
231
|
+
onProgress: (m) => log(' ' + m),
|
|
232
|
+
onStep: (s) => log(` [${s.done}/${s.total}] ${s.label}`),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ─── 复制用 HTML(微信 / 知乎 / 小红书)──────────────────────
|
|
237
|
+
function toHtml(md, target, theme = 'wechat') {
|
|
238
|
+
const { bodyHtml } = converter.renderMarkdown(md);
|
|
239
|
+
const th = themes.getTheme(theme);
|
|
240
|
+
if (target === 'wechat') return converter.buildWechatCopyHtml(bodyHtml, null, th);
|
|
241
|
+
if (target === 'zhihu') return zhihu.buildPublishHtml(bodyHtml);
|
|
242
|
+
if (target === 'xhs' || target === 'xiaohongshu') return converter.buildXhsCopyHtml(bodyHtml, th);
|
|
243
|
+
throw new Error(`未知目标:${target}(可选 wechat / zhihu / xhs)`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ─── 安装浏览器 ─────────────────────────────────────────────
|
|
247
|
+
function installBrowser({ log = console.error } = {}) {
|
|
248
|
+
return new Promise((resolve, reject) => {
|
|
249
|
+
log('正在下载 Chromium(约 150MB,只需一次)…');
|
|
250
|
+
const proc = spawn('npx', ['playwright', 'install', 'chromium'], { cwd: ROOT, stdio: 'inherit' });
|
|
251
|
+
proc.on('close', c => c === 0 ? resolve() : reject(new Error('Chromium 安装失败')));
|
|
252
|
+
proc.on('error', reject);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = {
|
|
257
|
+
ROOT, PLATFORMS, normPlatform,
|
|
258
|
+
listImages, listLocalImages, readMeta, socialFile, imagesDir,
|
|
259
|
+
exportImages, localCopy, llmCopy,
|
|
260
|
+
loadStore, addVersion, currentCopy,
|
|
261
|
+
login, status, publish, toHtml, installBrowser,
|
|
262
|
+
};
|