publishport-opencli 1.8.5-pp.5 → 1.8.5-pp.6

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/cli-manifest.json CHANGED
@@ -24669,6 +24669,80 @@
24669
24669
  "modulePath": "mdn/search.js",
24670
24670
  "sourceFile": "mdn/search.js"
24671
24671
  },
24672
+ {
24673
+ "site": "medium",
24674
+ "name": "article",
24675
+ "description": "发布 Medium 文章(走 medium.com 内部编辑器端点,非官方 API)。默认正式发布;加 --draft 仅存草稿。正文 Markdown,图片自动转存 Medium 图床。可选 --tags/--subtitle/--canonical-url。",
24676
+ "access": "write",
24677
+ "domain": "medium.com",
24678
+ "strategy": "cookie",
24679
+ "browser": true,
24680
+ "args": [
24681
+ {
24682
+ "name": "title",
24683
+ "type": "str",
24684
+ "required": true,
24685
+ "positional": true,
24686
+ "help": "文章标题(作为 Medium 文章标题)"
24687
+ },
24688
+ {
24689
+ "name": "text",
24690
+ "type": "str",
24691
+ "required": false,
24692
+ "positional": true,
24693
+ "help": "文章正文(Markdown)"
24694
+ },
24695
+ {
24696
+ "name": "file",
24697
+ "type": "str",
24698
+ "required": false,
24699
+ "help": "正文文件路径(UTF-8,Markdown)"
24700
+ },
24701
+ {
24702
+ "name": "tags",
24703
+ "type": "str",
24704
+ "required": false,
24705
+ "help": "Medium 话题标签,逗号分隔,最多 5 个(如 \"AI,Programming,Open Source\")"
24706
+ },
24707
+ {
24708
+ "name": "subtitle",
24709
+ "type": "str",
24710
+ "required": false,
24711
+ "help": "副标题(可选,显示在标题下方)"
24712
+ },
24713
+ {
24714
+ "name": "canonical-url",
24715
+ "type": "str",
24716
+ "required": false,
24717
+ "help": "原文规范链接(可选,内容首发别处时填,避免 SEO 重复)"
24718
+ },
24719
+ {
24720
+ "name": "draft",
24721
+ "type": "boolean",
24722
+ "required": false,
24723
+ "help": "仅保存草稿,不正式发布"
24724
+ },
24725
+ {
24726
+ "name": "execute",
24727
+ "type": "boolean",
24728
+ "required": false,
24729
+ "help": "确认执行写操作。不加此参数则拒绝写入。"
24730
+ }
24731
+ ],
24732
+ "columns": [
24733
+ "status",
24734
+ "outcome",
24735
+ "message",
24736
+ "target_type",
24737
+ "target",
24738
+ "created_target",
24739
+ "created_url"
24740
+ ],
24741
+ "type": "js",
24742
+ "modulePath": "medium/article.js",
24743
+ "sourceFile": "medium/article.js",
24744
+ "navigateBefore": "https://medium.com"
24745
+ },
24672
24746
  {
24673
24747
  "site": "medium",
24674
24748
  "name": "feed",
@@ -24706,6 +24780,36 @@
24706
24780
  "sourceFile": "medium/feed.js",
24707
24781
  "navigateBefore": "https://medium.com"
24708
24782
  },
24783
+ {
24784
+ "site": "medium",
24785
+ "name": "login",
24786
+ "description": "打开 Medium 首页并等待浏览器完成登录(供桌面客户端引导登录)。",
24787
+ "access": "write",
24788
+ "domain": "medium.com",
24789
+ "strategy": "cookie",
24790
+ "browser": true,
24791
+ "args": [
24792
+ {
24793
+ "name": "timeout",
24794
+ "type": "int",
24795
+ "default": 300,
24796
+ "required": false,
24797
+ "help": "等待用户完成登录的最长秒数"
24798
+ }
24799
+ ],
24800
+ "columns": [
24801
+ "status",
24802
+ "logged_in",
24803
+ "user_id",
24804
+ "username"
24805
+ ],
24806
+ "type": "js",
24807
+ "modulePath": "medium/login.js",
24808
+ "sourceFile": "medium/login.js",
24809
+ "navigateBefore": false,
24810
+ "siteSession": "persistent",
24811
+ "defaultWindowMode": "foreground"
24812
+ },
24709
24813
  {
24710
24814
  "site": "medium",
24711
24815
  "name": "search",
@@ -24818,6 +24922,25 @@
24818
24922
  "sourceFile": "medium/user.js",
24819
24923
  "navigateBefore": "https://medium.com"
24820
24924
  },
24925
+ {
24926
+ "site": "medium",
24927
+ "name": "whoami",
24928
+ "description": "查询当前登录的 Medium 账户信息。",
24929
+ "access": "read",
24930
+ "domain": "medium.com",
24931
+ "strategy": "cookie",
24932
+ "browser": true,
24933
+ "args": [],
24934
+ "columns": [
24935
+ "logged_in",
24936
+ "user_id",
24937
+ "username"
24938
+ ],
24939
+ "type": "js",
24940
+ "modulePath": "medium/whoami.js",
24941
+ "sourceFile": "medium/whoami.js",
24942
+ "navigateBefore": "https://medium.com"
24943
+ },
24821
24944
  {
24822
24945
  "site": "mubu",
24823
24946
  "name": "doc",
@@ -41602,6 +41725,36 @@
41602
41725
  "sourceFile": "weixin/drafts.js",
41603
41726
  "navigateBefore": false
41604
41727
  },
41728
+ {
41729
+ "site": "weixin",
41730
+ "name": "login",
41731
+ "description": "打开微信公众平台首页并等待浏览器完成扫码登录(供桌面客户端引导登录)。",
41732
+ "access": "write",
41733
+ "domain": "mp.weixin.qq.com",
41734
+ "strategy": "cookie",
41735
+ "browser": true,
41736
+ "args": [
41737
+ {
41738
+ "name": "timeout",
41739
+ "type": "int",
41740
+ "default": 300,
41741
+ "required": false,
41742
+ "help": "等待用户完成登录的最长秒数"
41743
+ }
41744
+ ],
41745
+ "columns": [
41746
+ "status",
41747
+ "logged_in",
41748
+ "user_id",
41749
+ "username"
41750
+ ],
41751
+ "type": "js",
41752
+ "modulePath": "weixin/login.js",
41753
+ "sourceFile": "weixin/login.js",
41754
+ "navigateBefore": false,
41755
+ "siteSession": "persistent",
41756
+ "defaultWindowMode": "foreground"
41757
+ },
41605
41758
  {
41606
41759
  "site": "weixin",
41607
41760
  "name": "search",
@@ -0,0 +1,397 @@
1
+ // @ts-check
2
+ import { cli, Strategy } from '@jackwener/opencli/registry';
3
+ import { CliError, CommandExecutionError } from '@jackwener/opencli/errors';
4
+ import { readFile, stat } from 'node:fs/promises';
5
+ import { publishArticle } from '../_shared/article/publish.js';
6
+
7
+ // ── Medium 发布 profile ──────────────────────────────────────────────────────
8
+ // 方案:Medium 内部端点(legacy delta editor + GraphQL),**非官方 API**——官方
9
+ // integration token 2023 起已停发新 token,早成死路。移植自开源 MIT 项目
10
+ // minanagehsalalma/medium-editor-mcp 的 legacy delta 编辑器实现,并据本机真实登录态
11
+ // 抓包核对(段落 type 枚举 / xsrf 来源 / 建稿→初始化→写delta→发布 全链路 200 通过)。
12
+ //
13
+ // 关键改造(相对参考实现):参考在 Node 侧手动拼 Cookie 头 + 从 cookie 读 xsrf;我们
14
+ // 跑在**已登录的真实浏览器页**里,全部走同源 fetch(credentials:'include'),浏览器自动带
15
+ // sid/uid/xsrf 三个 HttpOnly 会话 cookie。xsrf **token** 无法从 document.cookie 读到
16
+ // (HttpOnly),改从编辑器页 HTML 内嵌的 "xsrfToken":"..." 提取,塞进 x-xsrf-token 头。
17
+ //
18
+ // 端点(全部 https://medium.com 同源):
19
+ // 建稿 POST /new-story → payload.value.id = postId
20
+ // 初始化 POST /p/{id}/deltas {baseRev:-1, deltas: 草稿返回的 normalizingDeltas}
21
+ // 写正文 POST /p/{id}/deltas {baseRev, deltas:[{type:1,index,paragraph:{...}}]}
22
+ // 传图 POST /_/upload?is2x=true multipart uploadedFile → payload.value.{fileId,imgWidth,imgHeight}
23
+ // 标签 POST /_/graphql SetPostTagsMutation(targetPostId, tagNames)
24
+ // 规范链 POST /_/graphql UpdateCanonicalUrl(input:{postId,url})
25
+ // 发布 POST /p/{id}/publish 无 body
26
+ // 鉴权 POST /_/graphql ViewerQuery { viewer { id name username } }
27
+ //
28
+ // 段落 type(真机回读坐实):1=正文 2=大标题(H1 输入 12 会被归一为 2) 3=次级标题(且首块=文章标题)
29
+ // 4=副标题/图片 6=引用 8=代码 9=无序项 10=有序项 13=小标题(H4);markup:1=粗 2=斜 3=链接。
30
+
31
+ export const mediumProfile = {
32
+ home: 'https://medium.com/new-story',
33
+ outputFormat: 'markdown',
34
+
35
+ // ── 登录检测(whoami / login 复用)────────────────────────────────────────
36
+ // GraphQL ViewerQuery:同源 fetch,仅凭会话 cookie 即可(无需 xsrf)。
37
+ checkAuth: async (_PP) => {
38
+ try {
39
+ const resp = await fetch('https://medium.com/_/graphql', {
40
+ method: 'POST',
41
+ credentials: 'include',
42
+ headers: { 'content-type': 'application/json', 'accept': 'application/json' },
43
+ body: JSON.stringify({
44
+ operationName: 'ViewerQuery',
45
+ query: 'query ViewerQuery { viewer { id name username imageId } }',
46
+ variables: {},
47
+ }),
48
+ });
49
+ const data = await resp.json();
50
+ const v = data && data.data && data.data.viewer;
51
+ if (v && v.id) {
52
+ return {
53
+ isAuthenticated: true,
54
+ userId: v.id,
55
+ username: v.username || v.name || '',
56
+ avatar: v.imageId ? ('https://miro.medium.com/v2/resize:fill:96:96/' + v.imageId) : '',
57
+ };
58
+ }
59
+ return { isAuthenticated: false };
60
+ } catch (e) {
61
+ return { isAuthenticated: false, error: String((e && e.message) || e) };
62
+ }
63
+ },
64
+
65
+ // ── 发布函数(页面内序列化执行,带登录态)─────────────────────────────────
66
+ // I = { title, content, markdown, html, draftOnly, params }
67
+ // I.params = { tags: string[], subtitle?: string, canonicalUrl?: string }
68
+ // content 即原始 Markdown(Medium 不走通用图片转存,图片在本函数内传 /_/upload)。
69
+ publish: async (I, _PP) => {
70
+ // —— 工具:剥 Medium JSON 前缀 ])}while(1);</x> ——
71
+ const strip = (t) => t.replace(/^\s*\]\)\}while\(1\);(<\/x>)?/, '');
72
+
73
+ // —— 取 xsrf token:编辑器页 HTML 内嵌 "xsrfToken":"..."(HttpOnly cookie 读不到)——
74
+ const xm = document.documentElement.outerHTML.match(/"xsrfToken":"([^"]+)"/);
75
+ const xsrf = xm ? xm[1] : null;
76
+ if (!xsrf) {
77
+ return { ok: false, stage: 'xsrf', message: '未能从编辑器页提取 xsrfToken(请确认已登录 medium.com 且停留在 /new-story)' };
78
+ }
79
+
80
+ const H = {
81
+ 'accept': 'application/json',
82
+ 'x-requested-with': 'XMLHttpRequest',
83
+ 'x-xsrf-token': xsrf,
84
+ };
85
+ // —— 统一请求(JSON 端点)——
86
+ const api = async (path, method, body) => {
87
+ const headers = Object.assign({}, H);
88
+ if (body !== undefined) headers['content-type'] = 'application/json';
89
+ const r = await fetch('https://medium.com' + path, {
90
+ method: method || 'GET',
91
+ credentials: 'include',
92
+ headers,
93
+ body: body !== undefined ? JSON.stringify(body) : undefined,
94
+ });
95
+ let text = strip(await r.text());
96
+ let json = null;
97
+ try { json = JSON.parse(text); } catch (e) { /* 非 JSON */ }
98
+ return { status: r.status, json, text };
99
+ };
100
+
101
+ // —— 行内 Markdown → Medium markups(1=粗 2=斜 3=链接;行内代码退化为纯文本)——
102
+ const renderInline = (text) => {
103
+ const markups = [];
104
+ let i = 0, out = '';
105
+ const push = (v) => { if (v) out += v; };
106
+ while (i < text.length) {
107
+ const slice = text.slice(i);
108
+ const m = slice.match(/^(\[([^\]]+)\]\(([^)]+)\)|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*|(https?:\/\/[^\s<]+[^\s<.,;:!?")\]]))/);
109
+ if (!m) { push(text[i]); i += 1; continue; }
110
+ const start = i + (m.index || 0);
111
+ if (start > i) push(text.slice(i, start));
112
+ const full = m[1];
113
+ const s0 = out.length;
114
+ if (m[2] !== undefined && m[3] !== undefined) {
115
+ push(m[2]);
116
+ markups.push({ type: 3, start: s0, end: out.length, href: m[3], title: '', rel: 'nofollow', anchorType: 0 });
117
+ } else if (m[4] !== undefined) {
118
+ push(m[4]);
119
+ markups.push({ type: 1, start: s0, end: out.length });
120
+ } else if (m[5] !== undefined) {
121
+ push(m[5]); // 行内代码:Medium delta 无行内 code markup,退化为纯文本
122
+ } else if (m[6] !== undefined) {
123
+ push(m[6]);
124
+ markups.push({ type: 2, start: s0, end: out.length });
125
+ } else if (m[7] !== undefined) {
126
+ push(m[7]);
127
+ markups.push({ type: 3, start: s0, end: out.length, href: m[7], title: '', rel: 'nofollow', anchorType: 0 });
128
+ } else {
129
+ push(full);
130
+ }
131
+ i = start + full.length;
132
+ }
133
+ return { text: out, markups };
134
+ };
135
+
136
+ // —— Markdown → 段落块(type + text + markups;图片块带 imageUrl/imageAlt)——
137
+ // 移植自参考 parseMarkdownToMediumBlocks(h1→12 h2→2 h3→3 h4→13,Medium 侧会归一)。
138
+ const parseBlocks = (md) => {
139
+ const TYPE = { paragraph: 1, h1: 12, h2: 2, h3: 3, h4: 13, blockquote: 6, code: 8, 'ul-li': 9, 'ol-li': 10 };
140
+ const lines = (md || '').replace(/\r\n/g, '\n').split('\n');
141
+ const blocks = [];
142
+ let para = [];
143
+ let codeLang, codeLines = [], inCode = false;
144
+ const pushPara = (t, typeName, lang) => {
145
+ const nt = (t || '').trim();
146
+ if (!nt) return;
147
+ const type = TYPE[typeName] || TYPE.paragraph;
148
+ const inl = type === TYPE.code ? { text: nt, markups: [] } : renderInline(nt);
149
+ blocks.push(Object.assign({ type, text: inl.text, markups: inl.markups }, lang ? { codeLang: lang } : {}));
150
+ };
151
+ const flushPara = () => {
152
+ if (!para.length) return;
153
+ const text = para.join(' ').trim();
154
+ para = [];
155
+ if (!text) return;
156
+ const h = text.match(/^(#{1,4})\s+(.*)$/);
157
+ if (h) { pushPara(h[2].trim(), 'h' + h[1].length); return; }
158
+ const q = text.match(/^>\s?(.*)$/);
159
+ if (q) { pushPara(q[1].trim(), 'blockquote'); return; }
160
+ const ol = text.match(/^\d+\.\s+(.*)$/);
161
+ if (ol) { pushPara(ol[1].trim(), 'ol-li'); return; }
162
+ const ul = text.match(/^[-*]\s+(.*)$/);
163
+ if (ul) { pushPara(ul[1].trim(), 'ul-li'); return; }
164
+ pushPara(text, 'paragraph');
165
+ };
166
+ const flushCode = () => {
167
+ if (!codeLines.length) { codeLang = undefined; return; }
168
+ blocks.push(Object.assign({ type: TYPE.code, text: codeLines.join('\n'), markups: [] }, codeLang ? { codeLang } : {}));
169
+ codeLines = []; codeLang = undefined;
170
+ };
171
+ for (const line of lines) {
172
+ const imgMd = line.trim().match(/^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)$/i);
173
+ const imgHtml = line.trim().match(/^<img\b[^>]*?\bsrc=["']([^"']+)["'][^>]*>$/i);
174
+ if (imgMd) { flushPara(); blocks.push({ image: true, imageUrl: imgMd[2], imageAlt: imgMd[1] || '' }); continue; }
175
+ if (imgHtml) { flushPara(); const alt = line.match(/\balt=["']([^"']+)["']/i); blocks.push({ image: true, imageUrl: imgHtml[1], imageAlt: (alt && alt[1]) || '' }); continue; }
176
+ const fence = line.match(/^```(\S+)?\s*$/);
177
+ if (fence) {
178
+ if (inCode) { flushCode(); inCode = false; }
179
+ else { flushPara(); inCode = true; codeLang = fence[1] || undefined; }
180
+ continue;
181
+ }
182
+ if (inCode) { codeLines.push(line); continue; }
183
+ if (!line.trim()) { flushPara(); continue; }
184
+ if (/^(#{1,4})\s+/.test(line) || /^>\s?/.test(line) || /^\d+\.\s+/.test(line) || /^[-*]\s+/.test(line)) {
185
+ flushPara(); para.push(line); flushPara(); continue;
186
+ }
187
+ para.push(line.trim());
188
+ }
189
+ flushPara();
190
+ if (inCode) flushCode();
191
+ return blocks;
192
+ };
193
+
194
+ // —— 图片上传 /_/upload → { fileId, imgWidth, imgHeight } ——
195
+ const uploadImage = async (src) => {
196
+ let blob;
197
+ const r = await fetch(src, { credentials: 'omit' });
198
+ if (!r.ok) throw new Error('拉取图片失败 HTTP ' + r.status);
199
+ blob = await r.blob();
200
+ let name = 'image.png';
201
+ try { const u = new URL(src); const seg = u.pathname.split('/').filter(Boolean).pop() || 'image.png'; name = /\.[a-z0-9]+$/i.test(seg) ? seg : seg + '.png'; } catch (e) { /* data: 等 */ }
202
+ const form = new FormData();
203
+ form.append('uploadedFile', blob, name);
204
+ const up = await fetch('https://medium.com/_/upload?is2x=true', {
205
+ method: 'POST', credentials: 'include',
206
+ headers: { 'accept': 'application/json', 'x-requested-with': 'XMLHttpRequest', 'x-xsrf-token': xsrf },
207
+ body: form,
208
+ });
209
+ const val = JSON.parse(strip(await up.text()));
210
+ const v = (val && val.payload && val.payload.value) || {};
211
+ if (!v.fileId || !v.imgWidth || !v.imgHeight) throw new Error('上传响应缺 fileId/尺寸');
212
+ return { fileId: v.fileId, w: v.imgWidth, h: v.imgHeight };
213
+ };
214
+
215
+ const uploaded = [], failed = [];
216
+
217
+ // ═══ 1. 建草稿 ═══
218
+ const ns = await api('/new-story', 'POST', {});
219
+ const nsp = (ns.json && ns.json.payload) || {};
220
+ const postId = nsp.id || (nsp.value && nsp.value.id);
221
+ if (!postId) return { ok: false, stage: 'create', status: ns.status, message: 'Medium 未返回草稿 postId:' + ns.text.slice(0, 160) };
222
+
223
+ // ═══ 2. 读草稿初始态 + 初始化(在 rev -1 应用 normalizingDeltas)═══
224
+ const d0 = await api('/_/api/posts/' + postId + '/draft');
225
+ const p0 = (d0.json && d0.json.payload) || {};
226
+ const normDeltas = Array.isArray(p0.normalizingDeltas) ? p0.normalizingDeltas : [];
227
+ const rev0 = (p0.value && typeof p0.value.latestRev === 'number') ? p0.value.latestRev : 0;
228
+ if (rev0 < 0 && normDeltas.length) {
229
+ const initR = await api('/p/' + postId + '/deltas', 'POST', { baseRev: rev0, deltas: normDeltas });
230
+ if (initR.status >= 400) return { ok: false, stage: 'init', status: initR.status, message: '初始化草稿失败' };
231
+ }
232
+
233
+ // ═══ 3. 取 baseRev ═══
234
+ const pg = await api('/_/api/posts/' + postId);
235
+ const pv = (pg.json && pg.json.payload && pg.json.payload.value) || {};
236
+ const baseRev = typeof pv.latestRev === 'number' ? pv.latestRev : 0;
237
+
238
+ // ═══ 4. 组装段落块:标题(type3, 首块=文章标题) + 副标题(type4) + 正文块 ═══
239
+ const params = I.params || {};
240
+ let bodyBlocks = parseBlocks(I.markdown || I.content || '');
241
+ // 去掉与标题重复的首个 H1(避免正文里再出现一遍标题)
242
+ if (I.title && bodyBlocks.length && !bodyBlocks[0].image && bodyBlocks[0].type === 12 && (bodyBlocks[0].text || '').trim() === I.title.trim()) {
243
+ bodyBlocks = bodyBlocks.slice(1);
244
+ }
245
+ const head = [];
246
+ if (I.title && I.title.trim()) head.push({ type: 3, text: I.title.trim(), markups: [] });
247
+ if (params.subtitle && String(params.subtitle).trim()) head.push({ type: 4, text: String(params.subtitle).trim(), markups: [] });
248
+ const allBlocks = head.concat(bodyBlocks);
249
+
250
+ // ═══ 5. 块 → deltas(图片先传 /_/upload 再拼 image delta)═══
251
+ const deltas = [];
252
+ let idx = 0;
253
+ for (const b of allBlocks) {
254
+ if (b.image) {
255
+ try {
256
+ const im = await uploadImage(b.imageUrl);
257
+ deltas.push({ type: 1, index: idx, paragraph: { type: 4, text: b.imageAlt || '', markups: [], layout: 1, metadata: { id: im.fileId, originalWidth: im.w, originalHeight: im.h } } });
258
+ uploaded.push(b.imageUrl);
259
+ idx += 1;
260
+ } catch (e) {
261
+ failed.push({ src: b.imageUrl, error: String((e && e.message) || e) });
262
+ // 图片失败:跳过该块,不中断整篇发布
263
+ }
264
+ continue;
265
+ }
266
+ deltas.push(Object.assign({ type: 1, index: idx, paragraph: Object.assign({ type: b.type, text: b.text, markups: b.markups || [] }, b.codeLang ? { codeLang: b.codeLang } : {}) } ));
267
+ idx += 1;
268
+ }
269
+ if (!deltas.length) return { ok: false, stage: 'content', message: '正文为空,无可写入的段落' };
270
+
271
+ // ═══ 6. 写正文 ═══
272
+ const wr = await api('/p/' + postId + '/deltas', 'POST', { baseRev, deltas });
273
+ if (wr.status >= 400) return { ok: false, stage: 'write', status: wr.status, message: '写入正文失败:' + wr.text.slice(0, 160), uploaded, failed };
274
+
275
+ // ═══ 7. 标签 / canonical(GraphQL,可选)═══
276
+ const tags = Array.isArray(params.tags) ? params.tags.filter(Boolean).slice(0, 5) : [];
277
+ if (tags.length) {
278
+ const tg = await api('/_/graphql', 'POST', {
279
+ operationName: 'SetPostTagsMutation',
280
+ query: 'mutation SetPostTagsMutation($targetPostId: ID!, $tagNames: [String!]!) {\n setPostTags(targetPostId: $targetPostId, tagNames: $tagNames) {\n id\n }\n}',
281
+ variables: { targetPostId: postId, tagNames: tags },
282
+ });
283
+ if (tg.status >= 400 || (tg.json && tg.json.errors)) failed.push({ src: 'tags', error: '设置标签失败:' + (tg.text || '').slice(0, 120) });
284
+ }
285
+ if (params.canonicalUrl && String(params.canonicalUrl).trim()) {
286
+ await api('/_/graphql', 'POST', {
287
+ operationName: 'UpdateCanonicalUrl',
288
+ query: 'mutation UpdateCanonicalUrl($input: UpdateCanonicalUrlInput!) {\n updateCanonicalUrl(input: $input) {\n __typename\n }\n}',
289
+ variables: { input: { postId, url: String(params.canonicalUrl).trim() } },
290
+ });
291
+ }
292
+
293
+ // ═══ 8. 发布(draftOnly 则停在草稿)═══
294
+ const draftUrl = 'https://medium.com/p/' + postId + '/edit';
295
+ if (I.draftOnly) {
296
+ return { ok: true, id: postId, url: draftUrl, draft: true, uploaded, failed };
297
+ }
298
+ const pub = await api('/p/' + postId + '/publish', 'POST', {});
299
+ if (pub.status >= 400) return { ok: false, stage: 'publish', status: pub.status, message: '发布失败:' + pub.text.slice(0, 200), uploaded, failed };
300
+
301
+ // 回查真实文章 URL
302
+ const fin = await api('/_/api/posts/' + postId);
303
+ const fv = (fin.json && fin.json.payload && fin.json.payload.value) || {};
304
+ const url = fv.mediumUrl || (fv.uniqueSlug ? ('https://medium.com/p/' + postId) : ('https://medium.com/p/' + postId));
305
+ return { ok: true, id: postId, url, draft: false, uploaded, failed };
306
+ },
307
+ };
308
+
309
+ // checkAuth 供 whoami / login 复用。home 用轻量首页(GraphQL 探测无需编辑器页/xsrf),
310
+ // 不用发布档的 /new-story,避免读操作打开编辑器。
311
+ export const mediumAuthProfile = {
312
+ home: 'https://medium.com/',
313
+ checkAuth: mediumProfile.checkAuth,
314
+ };
315
+
316
+ // ── 本地小工具(与其它 article.js 一致)─────────────────────────────────────
317
+ function requireExecute(kwargs) {
318
+ if (!kwargs.execute) {
319
+ throw new CliError('INVALID_INPUT', '此命令需要 --execute 参数才能执行写操作');
320
+ }
321
+ }
322
+
323
+ async function resolvePayload(kwargs) {
324
+ const text = typeof kwargs.text === 'string' ? kwargs.text : undefined;
325
+ const file = typeof kwargs.file === 'string' ? kwargs.file : undefined;
326
+ if (text && file) throw new CliError('INVALID_INPUT', '不能同时指定正文参数和 --file,请二选一');
327
+ let resolved = text ?? '';
328
+ if (file) {
329
+ let fileStat;
330
+ try { fileStat = await stat(file); } catch { throw new CliError('INVALID_INPUT', '文件不存在: ' + file); }
331
+ if (!fileStat.isFile()) throw new CliError('INVALID_INPUT', '必须是可读的文本文件: ' + file);
332
+ let raw;
333
+ try { raw = await readFile(file); } catch { throw new CliError('INVALID_INPUT', '无法读取文件: ' + file); }
334
+ try { resolved = new TextDecoder('utf-8', { fatal: true }).decode(raw); }
335
+ catch { throw new CliError('INVALID_INPUT', '文件必须是 UTF-8 编码: ' + file); }
336
+ }
337
+ if (!resolved.trim()) throw new CliError('INVALID_INPUT', '正文不能为空');
338
+ return resolved;
339
+ }
340
+
341
+ function buildResultRow(message, target, url, outcome, extra = {}) {
342
+ return [{ status: 'success', outcome, message, target_type: 'article', target, ...extra }];
343
+ }
344
+
345
+ // ── CLI 注册 ─────────────────────────────────────────────────────────────
346
+ cli({
347
+ site: 'medium',
348
+ name: 'article',
349
+ access: 'write',
350
+ description: '发布 Medium 文章(走 medium.com 内部编辑器端点,非官方 API)。默认正式发布;加 --draft 仅存草稿。正文 Markdown,图片自动转存 Medium 图床。可选 --tags/--subtitle/--canonical-url。',
351
+ domain: 'medium.com',
352
+ strategy: Strategy.COOKIE,
353
+ browser: true,
354
+ args: [
355
+ { name: 'title', positional: true, required: true, help: '文章标题(作为 Medium 文章标题)' },
356
+ { name: 'text', positional: true, help: '文章正文(Markdown)' },
357
+ { name: 'file', help: '正文文件路径(UTF-8,Markdown)' },
358
+ { name: 'tags', help: 'Medium 话题标签,逗号分隔,最多 5 个(如 "AI,Programming,Open Source")' },
359
+ { name: 'subtitle', help: '副标题(可选,显示在标题下方)' },
360
+ { name: 'canonical-url', help: '原文规范链接(可选,内容首发别处时填,避免 SEO 重复)' },
361
+ { name: 'draft', type: 'boolean', help: '仅保存草稿,不正式发布' },
362
+ { name: 'execute', type: 'boolean', help: '确认执行写操作。不加此参数则拒绝写入。' },
363
+ ],
364
+ columns: ['status', 'outcome', 'message', 'target_type', 'target', 'created_target', 'created_url'],
365
+ func: async (page, kwargs) => {
366
+ if (!page) throw new CommandExecutionError('Medium 文章发布需要浏览器会话');
367
+ requireExecute(kwargs);
368
+ const title = String(kwargs.title ?? '').trim();
369
+ if (!title) throw new CliError('INVALID_INPUT', '文章标题不能为空');
370
+ const body = await resolvePayload(kwargs);
371
+ const draftOnly = Boolean(kwargs.draft);
372
+ const tags = String(kwargs.tags ?? '').split(',').map((s) => s.trim()).filter(Boolean);
373
+ const publishParams = {
374
+ tags,
375
+ subtitle: typeof kwargs.subtitle === 'string' ? kwargs.subtitle : '',
376
+ canonicalUrl: typeof kwargs['canonical-url'] === 'string' ? kwargs['canonical-url'] : '',
377
+ };
378
+
379
+ const result = await publishArticle(page, {
380
+ title,
381
+ body,
382
+ format: 'markdown',
383
+ draftOnly,
384
+ profile: mediumProfile,
385
+ publishParams,
386
+ });
387
+
388
+ const upN = (result.images.uploaded.length) | 0;
389
+ const failN = (result.images.failed.length) | 0;
390
+ let message = result.draft ? 'Medium 草稿已保存(可在编辑器内手动发布)' : 'Medium 文章已正式发布';
391
+ if (upN || failN) message += ';图片:' + upN + ' 张已转存' + (failN ? ',' + failN + ' 张失败' : '');
392
+ return buildResultRow(message, '', result.url, result.draft ? 'draft' : 'publish', {
393
+ created_target: 'article:' + result.id,
394
+ created_url: result.url,
395
+ });
396
+ },
397
+ });
@@ -0,0 +1,15 @@
1
+ import { registerArticleLogin } from '../_shared/article/login.js';
2
+ import { mediumAuthProfile } from './article.js';
3
+
4
+ // Medium login 命令:补桌面客户端「登录」按钮所需的 `medium login`。
5
+ // 登录态判定复用 mediumAuthProfile.checkAuth(GraphQL ViewerQuery,与 whoami 同源),
6
+ // 打开 medium.com 首页,用户完成登录后轮询登录态。缺了它绿点永远不亮、点登录只能干开站点页。
7
+ registerArticleLogin({
8
+ site: 'medium',
9
+ domain: 'medium.com',
10
+ profile: {
11
+ home: mediumAuthProfile.home,
12
+ checkAuth: mediumAuthProfile.checkAuth,
13
+ },
14
+ loginDescription: '打开 Medium 首页并等待浏览器完成登录(供桌面客户端引导登录)。',
15
+ });
@@ -0,0 +1,31 @@
1
+ // @ts-check
2
+ import { cli, Strategy } from '@jackwener/opencli/registry';
3
+ import { checkLogin, cookieQuickCheck } from '../_shared/article/auth.js';
4
+ import { mediumAuthProfile } from './article.js';
5
+
6
+ // Medium whoami:检测当前登录的 Medium 账户。
7
+ // checkAuth 走 GraphQL ViewerQuery(同源 fetch,仅凭会话 cookie)——见 article.js mediumProfile。
8
+ cli({
9
+ site: 'medium',
10
+ name: 'whoami',
11
+ access: 'read',
12
+ description: '查询当前登录的 Medium 账户信息。',
13
+ domain: 'medium.com',
14
+ strategy: Strategy.COOKIE,
15
+ browser: true,
16
+ columns: ['logged_in', 'user_id', 'username'],
17
+ // 快速登录检测(`auth status` quick / 桌面 GUI 用):Medium 后台会话由 sid(HttpOnly 会话)
18
+ // + uid(用户标识)承载,均在登录后才写入(cookieQuickCheck 走 CDP getCookies,能读 HttpOnly)。
19
+ // 命中任一即已登录。
20
+ authStatus: {
21
+ quickCheck: cookieQuickCheck('https://medium.com', ['sid', 'uid']),
22
+ },
23
+ func: async (page) => {
24
+ const r = await checkLogin(page, mediumAuthProfile);
25
+ return [{
26
+ logged_in: r.isAuthenticated,
27
+ user_id: r.userId || '',
28
+ username: r.username || '',
29
+ }];
30
+ },
31
+ });
@@ -123,18 +123,20 @@ export const weixinProfile = {
123
123
  },
124
124
 
125
125
  // 登录检测:移植自 Wechatsync WeixinAdapter.checkAuth()
126
- // 在 mp.weixin.qq.com 首页解析 token / nickName / avatar
126
+ // 在 mp.weixin.qq.com 首页解析账号标识 / nickName / avatar
127
127
  checkAuth: async (_PP) => {
128
128
  const resp = await fetch('https://mp.weixin.qq.com/', { method: 'GET', credentials: 'include' });
129
129
  const html = await resp.text();
130
130
 
131
- const tokenMatch = html.match(/data:\s*\{[\s\S]*?t:\s*["']([^"']+)["']/);
132
- if (!tokenMatch) {
131
+ // 判据用账号标识 user_name(gh_xxx),不能用 token——实测未登录首页里
132
+ // `data:{ ... t:"https://res.wx.qq.com/mpres/..." }` 会误命中 token 正则,
133
+ // 导致把登录页当成已登录。user_name / nick_name 只在已登录的公众平台首页出现。
134
+ const userNameMatch = html.match(/user_name:\s*["']([^"']+)["']/);
135
+ const nickNameMatch = html.match(/nick_name:\s*["']([^"']+)["']/);
136
+ if (!userNameMatch && !nickNameMatch) {
133
137
  return { isAuthenticated: false };
134
138
  }
135
139
 
136
- const nickNameMatch = html.match(/nick_name:\s*["']([^"']+)["']/);
137
- const userNameMatch = html.match(/user_name:\s*["']([^"']+)["']/);
138
140
  const avatarMatch = html.match(/class="weui-desktop-account__thumb"[^>]*src="([^"]+)"/);
139
141
  const headImgMatch = html.match(/head_img:\s*['"]([^'"]+)['"]/);
140
142
 
@@ -0,0 +1,15 @@
1
+ import { registerArticleLogin } from '../_shared/article/login.js';
2
+ import { weixinAuthProfile } from './article.js';
3
+
4
+ // 微信公众号 login 命令:补桌面客户端「登录」按钮所需的 `weixin login`。
5
+ // 登录态判定复用 weixinAuthProfile.checkAuth(与 whoami 同源),打开公众平台首页,
6
+ // 用户扫码登录后轮询登录态。缺了它绿点永远不亮、点登录只能干开站点页。
7
+ registerArticleLogin({
8
+ site: 'weixin',
9
+ domain: 'mp.weixin.qq.com',
10
+ profile: {
11
+ home: weixinAuthProfile.home,
12
+ checkAuth: weixinAuthProfile.checkAuth,
13
+ },
14
+ loginDescription: '打开微信公众平台首页并等待浏览器完成扫码登录(供桌面客户端引导登录)。',
15
+ });
@@ -1,11 +1,11 @@
1
1
  // @ts-check
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
- import { checkLogin } from '../_shared/article/auth.js';
3
+ import { checkLogin, cookieQuickCheck } from '../_shared/article/auth.js';
4
4
  import { weixinAuthProfile } from './article.js';
5
5
 
6
6
  // 微信公众号 whoami:检测当前登录的公众号账户信息。
7
7
  // checkAuth 移植自 Wechatsync WeixinAdapter.checkAuth(),解析 mp.weixin.qq.com
8
- // 首页 HTML 中的 token / nick_name / user_name / head_img 字段。
8
+ // 首页 HTML 中的 nick_name / user_name / head_img 字段。
9
9
  cli({
10
10
  site: 'weixin',
11
11
  name: 'whoami',
@@ -15,6 +15,13 @@ cli({
15
15
  strategy: Strategy.COOKIE,
16
16
  browser: true,
17
17
  columns: ['logged_in', 'user_id', 'username'],
18
+ // 快速登录检测(`auth status` quick / 桌面 GUI 用):mp.weixin.qq.com 后台会话
19
+ // 由 slave_sid(HttpOnly 会话)+ slave_user(gh_ 账号)承载,均在登录后才写入
20
+ // (实测:未登录只有 _qimei/_clck 等跟踪 cookie,扫码登录后才出现 slave_* 一组)。
21
+ // 命中任一即已登录。
22
+ authStatus: {
23
+ quickCheck: cookieQuickCheck('https://mp.weixin.qq.com', ['slave_sid', 'slave_user']),
24
+ },
18
25
  func: async (page) => {
19
26
  const r = await checkLogin(page, weixinAuthProfile);
20
27
  return [{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "publishport-opencli",
3
- "version": "1.8.5-pp.5",
3
+ "version": "1.8.5-pp.6",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },