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/lib/social.js ADDED
@@ -0,0 +1,264 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * lib/social.js — 小红书 / Twitter 的 cookie 管理 + Playwright 发布调度(host 侧)
5
+ *
6
+ * 隐私 & 安全:
7
+ * - cookie 只通过 storage(VS Code globalState)存取,不落磁盘文件、不被 git 追踪。
8
+ * - 发布时把 cookie 写到系统临时目录的一次性 job 文件,用完即删。
9
+ * - 唯一网络去向是平台自己(xiaohongshu.com / x.com),无任何第三方。
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+ const path = require('path');
15
+ const { spawn } = require('child_process');
16
+
17
+ const STORAGE_KEYS = {
18
+ xiaohongshu: 'xiaohongshu.cookies',
19
+ twitter: 'twitter.cookies',
20
+ zhihu: 'zhihu.browserCookies',
21
+ };
22
+
23
+ // 与 scripts/social_worker.js 保持一致
24
+ // 关键:小红书创作平台签发的是 creator 专属 cookie(不叫 web_session),
25
+ // 所以登录判定用「候选 cookie 命中任意一个」而非死盯单个名字。
26
+ const META = {
27
+ xiaohongshu: {
28
+ name: '小红书',
29
+ loginUrl: 'https://creator.xiaohongshu.com/login',
30
+ cookieDomain: '.xiaohongshu.com',
31
+ authCookies: [
32
+ 'access-token-creator.xiaohongshu.com',
33
+ 'galaxy_creator_session_id',
34
+ 'galaxy.creator.beaker.session.id',
35
+ 'customer-sso-sid',
36
+ 'customerClientId',
37
+ 'web_session',
38
+ ],
39
+ },
40
+ twitter: {
41
+ name: 'Twitter',
42
+ loginUrl: 'https://x.com/login',
43
+ cookieDomain: '.x.com',
44
+ authCookies: ['auth_token'],
45
+ },
46
+ zhihu: {
47
+ name: '知乎',
48
+ loginUrl: 'https://www.zhihu.com/signin',
49
+ cookieDomain: '.zhihu.com',
50
+ authCookies: ['z_c0'],
51
+ },
52
+ };
53
+
54
+ // ─── cookie 存取(委托 storage) ────────────────────────────
55
+ function getCookies(platform, storage) {
56
+ try { return JSON.parse(storage.get(STORAGE_KEYS[platform]) || '[]'); }
57
+ catch (_) { return []; }
58
+ }
59
+ function setCookies(platform, storage, cookiesArr) {
60
+ storage.set(STORAGE_KEYS[platform], JSON.stringify(cookiesArr || []));
61
+ }
62
+ function clearCookies(platform, storage) {
63
+ storage.set(STORAGE_KEYS[platform], '');
64
+ }
65
+
66
+ /**
67
+ * 计算登录态 / 有效期
68
+ * @returns {{ loggedIn, expiresAt:(number|null), daysLeft:(number|null), state:'valid'|'soon'|'expired'|'none' }}
69
+ */
70
+ function cookieStatus(platform, cookies) {
71
+ const meta = META[platform];
72
+ const list = cookies || [];
73
+ const auth = list.find(c => meta.authCookies.includes(c.name) && c.value);
74
+ if (!auth) return { loggedIn: false, expiresAt: null, daysLeft: null, state: 'none' };
75
+
76
+ const now = Date.now() / 1000;
77
+
78
+ // 有效期:优先用 auth cookie 自己的 expires;它若是会话 cookie(无 expires,
79
+ // 小红书 web_session 常见),退而取该平台其余 cookie 里最长的一个有效期做估算。
80
+ let exp = (typeof auth.expires === 'number' && auth.expires > 0) ? auth.expires : null;
81
+ if (exp == null) {
82
+ const candidates = list
83
+ .map(c => (typeof c.expires === 'number' ? c.expires : -1))
84
+ .filter(e => e > now);
85
+ if (candidates.length) exp = Math.max(...candidates);
86
+ }
87
+
88
+ if (exp && exp <= now) return { loggedIn: false, expiresAt: exp * 1000, daysLeft: 0, state: 'expired' };
89
+
90
+ const daysLeft = exp ? Math.max(0, Math.floor((exp - now) / 86400)) : null;
91
+ const state = exp == null ? 'valid' : (daysLeft <= 2 ? 'soon' : 'valid');
92
+ return { loggedIn: true, expiresAt: exp ? exp * 1000 : null, daysLeft, state };
93
+ }
94
+
95
+ /**
96
+ * 解析用户手动粘贴的 cookie(兜底入口)
97
+ * 支持两种格式:
98
+ * 1) 浏览器里复制的 "name=value; name2=value2"
99
+ * 2) Playwright/扩展导出的 JSON 数组
100
+ * @returns {Array} Playwright 格式 cookie 数组
101
+ */
102
+ function parsePastedCookies(platform, raw) {
103
+ const meta = META[platform];
104
+ const text = String(raw || '').trim();
105
+ if (!text) throw new Error('Cookie 不能为空');
106
+
107
+ // JSON 数组
108
+ if (text.startsWith('[')) {
109
+ const arr = JSON.parse(text);
110
+ if (!Array.isArray(arr)) throw new Error('JSON 不是 cookie 数组');
111
+ return arr.map(c => ({
112
+ name: c.name, value: c.value,
113
+ domain: c.domain || meta.cookieDomain,
114
+ path: c.path || '/',
115
+ expires: typeof c.expires === 'number' ? c.expires : -1,
116
+ httpOnly: !!c.httpOnly, secure: c.secure !== false, sameSite: c.sameSite || 'Lax',
117
+ })).filter(c => c.name && c.value);
118
+ }
119
+
120
+ // "name=value; name2=value2"
121
+ const out = [];
122
+ for (const pair of text.split(/;\s*/)) {
123
+ const i = pair.indexOf('=');
124
+ if (i <= 0) continue;
125
+ const name = pair.slice(0, i).trim();
126
+ const value = pair.slice(i + 1).trim();
127
+ if (!name || !value) continue;
128
+ out.push({
129
+ name, value,
130
+ domain: meta.cookieDomain, path: '/',
131
+ expires: -1, httpOnly: false, secure: true, sameSite: 'Lax',
132
+ });
133
+ }
134
+ if (!out.length) throw new Error('未解析出任何 cookie');
135
+ if (!out.some(c => meta.authCookies.includes(c.name))) {
136
+ throw new Error(`缺少登录 cookie(需包含以下任一:${meta.authCookies.join(' / ')}),请确认复制完整`);
137
+ }
138
+ return out;
139
+ }
140
+
141
+ // ─── 调用 Playwright worker ─────────────────────────────────
142
+ function workerPath(extensionPath) {
143
+ return path.join(extensionPath, 'scripts', 'social_worker.js');
144
+ }
145
+
146
+ /**
147
+ * 登录:启动可见浏览器让用户登录,抓到 cookie 后存入 storage
148
+ * @returns {Promise<{ cookies }>} 同时已写入 storage
149
+ */
150
+ function login(platform, { extensionPath, storage, onProgress, onNeedInstall, onChild }) {
151
+ return new Promise((resolve, reject) => {
152
+ const cookieOut = path.join(os.tmpdir(), `m2a_${platform}_cookie_${Date.now()}.json`);
153
+ const proc = spawn(process.execPath, [workerPath(extensionPath), 'login', platform, cookieOut]);
154
+ if (onChild) onChild(proc);
155
+ let stderr = '';
156
+ let needInstall = false;
157
+
158
+ proc.stdout.on('data', d => {
159
+ for (const line of d.toString().split('\n')) {
160
+ if (line.startsWith('INFO:')) onProgress && onProgress(line.slice(5));
161
+ else if (line.startsWith('NEED_INSTALL')) needInstall = true;
162
+ else if (line.startsWith('COOKIES_SAVED')) { /* handled on close */ }
163
+ else if (line.startsWith('ERROR:')) stderr = line.slice(6);
164
+ }
165
+ });
166
+ proc.stderr.on('data', d => { stderr += d.toString(); });
167
+
168
+ proc.on('close', (code) => {
169
+ if (needInstall && code === 2) { const e = new Error('NEED_INSTALL'); e.needInstall = true; reject(e); return; }
170
+ if (code !== 0) { reject(new Error(stderr || `登录进程退出码 ${code}`)); return; }
171
+ try {
172
+ const cookies = JSON.parse(fs.readFileSync(cookieOut, 'utf8'));
173
+ fs.unlinkSync(cookieOut);
174
+ setCookies(platform, storage, cookies);
175
+ resolve({ cookies });
176
+ } catch (e) { reject(new Error('读取登录 cookie 失败:' + e.message)); }
177
+ });
178
+ proc.on('error', reject);
179
+ });
180
+ }
181
+
182
+ /**
183
+ * 发布:注入 cookie,自动填内容并(prepare 模式)停在发布前
184
+ * prepare 模式下浏览器保持打开、子进程常驻,resolve 后不 kill。
185
+ * @returns {Promise<{ status:'ready'|'published', child }>}
186
+ */
187
+ function publish(platform, { extensionPath, cookies, content, images, link, mode = 'prepare', headless = false, onProgress, onStep, onNeedInstall, onChild }) {
188
+ return new Promise((resolve, reject) => {
189
+ const job = {
190
+ cookies,
191
+ title: content.title || '', body: content.body || '', tags: content.tags || [],
192
+ tweets: content.tweets || null, // Twitter 串推
193
+ autoNumber: content.autoNumber !== false, // 自动加 1/N 编号
194
+ linkPos: content.linkPos || 'all', // 全文链接放哪几条
195
+ oneImagePerTweet: content.oneImagePerTweet !== false, // 一条一图
196
+ html: content.html || '', // 知乎:干净的发布 HTML
197
+ images: images || [], link: link || '', mode, headless,
198
+ };
199
+ const jobFile = path.join(os.tmpdir(), `m2a_${platform}_job_${Date.now()}.json`);
200
+ fs.writeFileSync(jobFile, JSON.stringify(job), 'utf8');
201
+
202
+ // 注意:job 文件【不删】—— resume 时要复用它(临时目录由系统回收)
203
+ runWorker('publish', platform, jobFile, extensionPath, { onProgress, onStep, onNeedInstall, onChild, resolve, reject });
204
+ });
205
+ }
206
+
207
+ /**
208
+ * 断点续传:重连上次那个还开着的浏览器,从中断处继续
209
+ */
210
+ function resume(platform, { extensionPath, jobFile, onProgress, onStep, onChild }) {
211
+ return new Promise((resolve, reject) => {
212
+ if (!jobFile || !fs.existsSync(jobFile)) { reject(new Error('没有可续传的任务(job 已失效,请重新发布)')); return; }
213
+ runWorker('resume', platform, jobFile, extensionPath, { onProgress, onStep, onChild, resolve, reject });
214
+ });
215
+ }
216
+
217
+ /** 统一的 worker 调度 + stdout 协议解析 */
218
+ function runWorker(cmd, platform, jobFile, extensionPath, { onProgress, onStep, onNeedInstall, onChild, resolve, reject }) {
219
+ const proc = spawn(process.execPath, [workerPath(extensionPath), cmd, platform, jobFile]);
220
+ if (onChild) onChild(proc); // 交给调用方保管,便于下次发布前先杀掉旧的
221
+ let settled = false;
222
+ let errMsg = '';
223
+ let diagDir = '';
224
+
225
+ proc.stdout.on('data', d => {
226
+ for (const line of d.toString().split('\n')) {
227
+ if (line.startsWith('INFO:')) onProgress && onProgress(line.slice(5));
228
+ else if (line.startsWith('PROGRESS:')) {
229
+ // PROGRESS:<done>/<total>:<label>
230
+ const m = line.slice(9).match(/^(\d+)\/(\d+):(.*)$/);
231
+ if (m && onStep) onStep({ done: +m[1], total: +m[2], label: m[3] });
232
+ }
233
+ else if (line.startsWith('NEED_INSTALL')) { onNeedInstall && onNeedInstall(); }
234
+ else if (line.startsWith('DIAG:')) { diagDir = line.slice(5).trim(); }
235
+ else if (line.startsWith('READY_TO_PUBLISH')) {
236
+ if (!settled) { settled = true; resolve({ status: 'ready', child: proc, jobFile }); }
237
+ } else if (line.startsWith('PUBLISHED:')) {
238
+ if (!settled) { settled = true; resolve({ status: 'published', child: proc, jobFile, url: line.slice(10) }); }
239
+ } else if (line.startsWith('ERROR:')) {
240
+ errMsg = line.slice(6);
241
+ // 出错时 worker 保留浏览器并挂住,不会 close → 这里立刻 reject
242
+ if (!settled) {
243
+ settled = true;
244
+ const e = new Error(errMsg + (diagDir ? `\n现场存证:${diagDir}` : ''));
245
+ e.diagDir = diagDir;
246
+ e.jobFile = jobFile; // 供 resume 使用
247
+ e.canResume = true;
248
+ reject(e);
249
+ }
250
+ }
251
+ }
252
+ });
253
+
254
+ proc.on('close', (code) => {
255
+ if (!settled) { settled = true; reject(new Error(errMsg || `进程退出码 ${code}`)); }
256
+ });
257
+ proc.on('error', (e) => { if (!settled) { settled = true; reject(e); } });
258
+ }
259
+
260
+ module.exports = {
261
+ STORAGE_KEYS, META,
262
+ getCookies, setCookies, clearCookies, cookieStatus, parsePastedCookies,
263
+ login, publish, resume,
264
+ };
package/lib/store.js ADDED
@@ -0,0 +1,70 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * CLI 的配置与凭证存储。
5
+ *
6
+ * VS Code 插件里 cookie 走 globalState、API Key 走系统钥匙串(SecretStorage)。
7
+ * CLI 没有这两样,所以落盘到 ~/.config/md2any/,并把权限收紧到 0600(仅本人可读写)。
8
+ *
9
+ * 安全约定(和插件一致):
10
+ * - 凭证只存本地,唯一的网络去向是平台自己 / 你自己配的 LLM 端点
11
+ * - 不经过任何第三方服务
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const os = require('os');
16
+ const path = require('path');
17
+
18
+ const DIR = process.env.MD2ANY_HOME || path.join(os.homedir(), '.config', 'md2any');
19
+ const CONFIG_FILE = path.join(DIR, 'config.json');
20
+ const COOKIES_FILE = path.join(DIR, 'cookies.json');
21
+
22
+ function ensureDir() {
23
+ fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
24
+ }
25
+
26
+ function readJson(file, fallback) {
27
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
28
+ catch (_) { return fallback; }
29
+ }
30
+
31
+ function writeJson(file, obj) {
32
+ ensureDir();
33
+ fs.writeFileSync(file, JSON.stringify(obj, null, 2) + '\n', { mode: 0o600 });
34
+ try { fs.chmodSync(file, 0o600); } catch (_) {}
35
+ }
36
+
37
+ // ─── LLM 配置 ────────────────────────────────────────────────
38
+ /** 环境变量优先,其次 ~/.config/md2any/config.json */
39
+ function getLlmConfig() {
40
+ const cfg = readJson(CONFIG_FILE, {}).llm || {};
41
+ return {
42
+ baseUrl: process.env.MD2ANY_LLM_BASE_URL || cfg.baseUrl || '',
43
+ model: process.env.MD2ANY_LLM_MODEL || cfg.model || '',
44
+ apiKey: process.env.MD2ANY_LLM_API_KEY || cfg.apiKey || '',
45
+ };
46
+ }
47
+
48
+ function setLlmConfig(patch) {
49
+ const all = readJson(CONFIG_FILE, {});
50
+ all.llm = Object.assign({}, all.llm, patch);
51
+ writeJson(CONFIG_FILE, all);
52
+ return all.llm;
53
+ }
54
+
55
+ // ─── Cookie 存储(适配 lib/social.js 的 storage 接口)────────
56
+ function cookieStorage() {
57
+ return {
58
+ get: (key) => {
59
+ const all = readJson(COOKIES_FILE, {});
60
+ return all[key] || '';
61
+ },
62
+ set: (key, val) => {
63
+ const all = readJson(COOKIES_FILE, {});
64
+ all[key] = val;
65
+ writeJson(COOKIES_FILE, all);
66
+ },
67
+ };
68
+ }
69
+
70
+ module.exports = { DIR, CONFIG_FILE, COOKIES_FILE, getLlmConfig, setLlmConfig, cookieStorage, readJson, writeJson };