Skip to content

Commit c0a49e4

Browse files
feat(weixin): add create-draft and drafts commands for Official Account (#1095)
* feat(weixin): add publish (create draft with cover) and drafts (list drafts) Closes #441 * fix(weixin): rename publish to create-draft to match issue #441 proposal * fix(weixin): fail fast on draft auth and empty states * test(weixin): align adapter imports with repo style --------- Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent 6827de4 commit c0a49e4

6 files changed

Lines changed: 445 additions & 1 deletion

File tree

cli-manifest.json

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17341,6 +17341,56 @@
1734117341
"sourceFile": "weibo/user.js",
1734217342
"navigateBefore": "https://weibo.com"
1734317343
},
17344+
{
17345+
"site": "weixin",
17346+
"name": "create-draft",
17347+
"description": "创建微信公众号图文草稿",
17348+
"domain": "mp.weixin.qq.com",
17349+
"strategy": "cookie",
17350+
"browser": true,
17351+
"args": [
17352+
{
17353+
"name": "title",
17354+
"type": "str",
17355+
"required": true,
17356+
"help": "文章标题 (最长64字)"
17357+
},
17358+
{
17359+
"name": "content",
17360+
"type": "str",
17361+
"required": true,
17362+
"positional": true,
17363+
"help": "文章正文"
17364+
},
17365+
{
17366+
"name": "author",
17367+
"type": "str",
17368+
"required": false,
17369+
"help": "作者名 (最长8字)"
17370+
},
17371+
{
17372+
"name": "cover-image",
17373+
"type": "str",
17374+
"required": false,
17375+
"help": "封面图片路径 (会先上传到正文再设为封面)"
17376+
},
17377+
{
17378+
"name": "summary",
17379+
"type": "str",
17380+
"required": false,
17381+
"help": "文章摘要"
17382+
}
17383+
],
17384+
"columns": [
17385+
"status",
17386+
"detail"
17387+
],
17388+
"timeout": 180,
17389+
"type": "js",
17390+
"modulePath": "weixin/create-draft.js",
17391+
"sourceFile": "weixin/create-draft.js",
17392+
"navigateBefore": false
17393+
},
1734417394
{
1734517395
"site": "weixin",
1734617396
"name": "download",
@@ -17383,6 +17433,33 @@
1738317433
"sourceFile": "weixin/download.js",
1738417434
"navigateBefore": "https://mp.weixin.qq.com"
1738517435
},
17436+
{
17437+
"site": "weixin",
17438+
"name": "drafts",
17439+
"description": "列出微信公众号草稿箱",
17440+
"domain": "mp.weixin.qq.com",
17441+
"strategy": "cookie",
17442+
"browser": true,
17443+
"args": [
17444+
{
17445+
"name": "limit",
17446+
"type": "int",
17447+
"default": 10,
17448+
"required": false,
17449+
"help": "最多显示条数"
17450+
}
17451+
],
17452+
"columns": [
17453+
"Index",
17454+
"Title",
17455+
"Time"
17456+
],
17457+
"timeout": 60,
17458+
"type": "js",
17459+
"modulePath": "weixin/drafts.js",
17460+
"sourceFile": "weixin/drafts.js",
17461+
"navigateBefore": false
17462+
},
1738617463
{
1738717464
"site": "weread",
1738817465
"name": "ai-outline",

clis/weixin/create-draft.js

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import { cli, Strategy } from '@jackwener/opencli/registry';
2+
import { CommandExecutionError } from '@jackwener/opencli/errors';
3+
4+
const WEIXIN_DOMAIN = 'mp.weixin.qq.com';
5+
const WEIXIN_HOME = 'https://mp.weixin.qq.com/';
6+
7+
async function getToken(page) {
8+
return page.evaluate(`(window.location.href.match(/token=(\\d+)/)||[])[1]`);
9+
}
10+
11+
async function navigateToEditor(page) {
12+
await page.goto(WEIXIN_HOME);
13+
await page.wait(3);
14+
const token = await getToken(page);
15+
if (!token) {
16+
throw new CommandExecutionError('Could not extract session token. Please log in to mp.weixin.qq.com');
17+
}
18+
await page.goto(`https://mp.weixin.qq.com/cgi-bin/appmsg?t=media/appmsg_edit_v2&action=edit&isNew=1&type=77&token=${token}&lang=zh_CN`);
19+
await page.wait(4);
20+
const hasTitle = await page.evaluate('!!document.querySelector("textarea#title")');
21+
if (!hasTitle) {
22+
throw new CommandExecutionError('Article editor did not load. Session may have expired');
23+
}
24+
}
25+
26+
async function fillField(page, selector, value) {
27+
return page.evaluate(`(() => {
28+
var el = document.querySelector('${selector}');
29+
if (!el) return { ok: false, reason: 'not found: ${selector}' };
30+
el.focus();
31+
var proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
32+
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
33+
if (setter && setter.set) setter.set.call(el, ${JSON.stringify(value)});
34+
else el.value = ${JSON.stringify(value)};
35+
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ${JSON.stringify(value)} }));
36+
el.dispatchEvent(new Event('change', { bubbles: true }));
37+
el.blur();
38+
return { ok: true };
39+
})()`);
40+
}
41+
42+
async function fillContent(page, text) {
43+
return page.evaluate(`(() => {
44+
var editors = document.querySelectorAll('div[contenteditable="true"]');
45+
var editor = editors[editors.length - 1];
46+
if (!editor) return { ok: false, reason: 'content editor not found' };
47+
editor.focus();
48+
if (editor.querySelector('[contenteditable="false"]')) editor.innerHTML = '';
49+
document.execCommand('selectAll', false, null);
50+
document.execCommand('insertText', false, ${JSON.stringify(text)});
51+
editor.dispatchEvent(new InputEvent('input', { bubbles: true }));
52+
return { ok: true };
53+
})()`);
54+
}
55+
56+
async function uploadContentImage(page, imagePath) {
57+
const fs = await import('node:fs');
58+
const path = await import('node:path');
59+
const absPath = path.default.resolve(imagePath);
60+
if (!fs.default.existsSync(absPath)) {
61+
throw new CommandExecutionError(`Image not found: ${absPath}`);
62+
}
63+
if (!page.setFileInput) {
64+
throw new CommandExecutionError('Image upload requires Browser Bridge with CDP support');
65+
}
66+
67+
await page.evaluate(`(() => {
68+
var li = document.querySelector('#js_editor_insertimage');
69+
if (li) li.click();
70+
})()`);
71+
await page.wait(1);
72+
await page.evaluate(`(() => {
73+
var items = document.querySelectorAll('.js_img_dropdown_menu .tpl_dropdown_menu_item');
74+
if (items[0]) items[0].click();
75+
})()`);
76+
await page.wait(1);
77+
78+
await page.setFileInput([absPath], 'input[type="file"][name="file"]');
79+
await page.wait(8);
80+
81+
const cdnCount = await page.evaluate(`(() => {
82+
var editor = document.querySelector('#ueditor_0');
83+
return editor ? editor.querySelectorAll('img[src*="mmbiz"]').length : 0;
84+
})()`);
85+
if (cdnCount === 0) {
86+
throw new CommandExecutionError('Image did not upload to WeChat CDN');
87+
}
88+
}
89+
90+
async function selectCoverFromContent(page) {
91+
await page.evaluate('document.querySelector("#js_cover_description_area")?.scrollIntoView()');
92+
await page.wait(1);
93+
94+
await page.evaluate('document.querySelector(".js_cover_btn_area")?.click()');
95+
await page.wait(1);
96+
97+
await page.evaluate(`(() => {
98+
var links = document.querySelectorAll('a.pop-opr__button');
99+
for (var i = 0; i < links.length; i++) {
100+
if (links[i].textContent.trim() === '从正文选择') { links[i].click(); return; }
101+
}
102+
})()`);
103+
await page.wait(2);
104+
105+
await page.evaluate(`(() => {
106+
var img = document.querySelector('.weui-desktop-dialog_img-picker .appmsg_content_img');
107+
if (img) img.click();
108+
})()`);
109+
await page.wait(1);
110+
111+
await page.evaluate(`(() => {
112+
var btns = document.querySelectorAll('.weui-desktop-dialog_img-picker button');
113+
for (var i = 0; i < btns.length; i++) {
114+
if (btns[i].textContent.trim() === '下一步' && !btns[i].disabled) { btns[i].click(); return; }
115+
}
116+
})()`);
117+
118+
// Crop dialog image rendering can be slow
119+
for (let attempt = 0; attempt < 8; attempt++) {
120+
await page.wait(2);
121+
const ready = await page.evaluate(`(() => {
122+
var btns = document.querySelectorAll('button');
123+
for (var i = 0; i < btns.length; i++) {
124+
if (btns[i].textContent.trim() === '确认' && btns[i].offsetHeight > 0 && !btns[i].disabled) return true;
125+
}
126+
return false;
127+
})()`);
128+
if (ready) break;
129+
}
130+
131+
await page.evaluate(`(() => {
132+
var btns = document.querySelectorAll('button');
133+
for (var i = 0; i < btns.length; i++) {
134+
if (btns[i].textContent.trim() === '确认' && btns[i].offsetHeight > 0 && !btns[i].disabled) { btns[i].click(); return; }
135+
}
136+
})()`);
137+
await page.wait(2);
138+
const hasCover = await page.evaluate(`(() => {
139+
var area = document.querySelector('#js_cover_area');
140+
if (!area) return false;
141+
var found = false;
142+
area.querySelectorAll('*').forEach(function(el) {
143+
var bg = window.getComputedStyle(el).backgroundImage;
144+
if (bg && bg.includes('mmbiz')) found = true;
145+
});
146+
return found;
147+
})()`);
148+
return hasCover;
149+
}
150+
151+
async function clickSaveDraft(page) {
152+
const result = await page.evaluate(`(() => {
153+
var btns = document.querySelectorAll('span, button, a');
154+
for (var i = 0; i < btns.length; i++) {
155+
if ((btns[i].textContent || '').trim() === '保存为草稿') { btns[i].click(); return { ok: true }; }
156+
}
157+
return { ok: false };
158+
})()`);
159+
if (!result?.ok) throw new CommandExecutionError('Save draft button not found');
160+
161+
for (let attempt = 0; attempt < 5; attempt++) {
162+
await page.wait(2);
163+
const saved = await page.evaluate(`(() => {
164+
var el = document.querySelector('#js_save_success');
165+
if (el && window.getComputedStyle(el).display !== 'none') return true;
166+
return document.body.innerText.includes('已保存');
167+
})()`);
168+
if (saved) return true;
169+
}
170+
return false;
171+
}
172+
173+
export const createDraftCommand = cli({
174+
site: 'weixin',
175+
name: 'create-draft',
176+
description: '创建微信公众号图文草稿',
177+
domain: WEIXIN_DOMAIN,
178+
strategy: Strategy.COOKIE,
179+
browser: true,
180+
navigateBefore: false,
181+
timeoutSeconds: 180,
182+
args: [
183+
{ name: 'title', required: true, help: '文章标题 (最长64字)' },
184+
{ name: 'content', required: true, positional: true, help: '文章正文' },
185+
{ name: 'author', help: '作者名 (最长8字)' },
186+
{ name: 'cover-image', help: '封面图片路径 (会先上传到正文再设为封面)' },
187+
{ name: 'summary', help: '文章摘要' },
188+
],
189+
columns: ['status', 'detail'],
190+
191+
func: async (page, kwargs) => {
192+
await navigateToEditor(page);
193+
194+
const titleResult = await fillField(page, 'textarea#title', kwargs.title);
195+
if (!titleResult?.ok) throw new CommandExecutionError('Failed to fill title');
196+
197+
if (kwargs.author) {
198+
const authorResult = await fillField(page, 'input#author', kwargs.author);
199+
if (!authorResult?.ok) throw new CommandExecutionError('Failed to fill author');
200+
}
201+
202+
const contentResult = await fillContent(page, kwargs.content);
203+
if (!contentResult?.ok) throw new CommandExecutionError('Failed to fill content');
204+
205+
if (kwargs['cover-image']) {
206+
await uploadContentImage(page, kwargs['cover-image']);
207+
const coverSet = await selectCoverFromContent(page);
208+
if (!coverSet) {
209+
// Non-fatal: draft can be saved without cover
210+
}
211+
}
212+
213+
if (kwargs.summary) {
214+
await fillField(page, 'textarea#js_description', kwargs.summary);
215+
}
216+
217+
await page.wait(1);
218+
const success = await clickSaveDraft(page);
219+
220+
return [{
221+
status: success ? 'draft saved' : 'save attempted, check browser to confirm',
222+
detail: `"${kwargs.title}"${kwargs.author ? ` by ${kwargs.author}` : ''}${kwargs['cover-image'] ? ' (with cover)' : ''}`,
223+
}];
224+
},
225+
});

clis/weixin/drafts.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
2+
import { cli, Strategy } from '@jackwener/opencli/registry';
3+
4+
const WEIXIN_DOMAIN = 'mp.weixin.qq.com';
5+
6+
export const draftsCommand = cli({
7+
site: 'weixin',
8+
name: 'drafts',
9+
description: '列出微信公众号草稿箱',
10+
domain: WEIXIN_DOMAIN,
11+
strategy: Strategy.COOKIE,
12+
browser: true,
13+
navigateBefore: false,
14+
timeoutSeconds: 60,
15+
args: [
16+
{ name: 'limit', type: 'int', default: 10, help: '最多显示条数' },
17+
],
18+
columns: ['Index', 'Title', 'Time'],
19+
20+
func: async (page, kwargs) => {
21+
await page.goto('https://mp.weixin.qq.com/');
22+
await page.wait(3);
23+
const token = await page.evaluate(`(window.location.href.match(/token=(\\d+)/)||[])[1]`);
24+
if (!token) {
25+
throw new AuthRequiredError(WEIXIN_DOMAIN, '微信公众号草稿箱需要已登录的 mp.weixin.qq.com 会话');
26+
}
27+
28+
await page.goto(`https://mp.weixin.qq.com/cgi-bin/appmsg?begin=0&count=${kwargs.limit}&type=77&action=list_card&token=${token}&lang=zh_CN`);
29+
await page.wait(4);
30+
31+
const drafts = await page.evaluate(`(() => {
32+
var results = [];
33+
var idx = 0;
34+
35+
var cards = document.querySelectorAll('.weui-desktop-card');
36+
for (var i = 0; i < cards.length; i++) {
37+
if (cards[i].className.includes('card_new')) continue;
38+
var titleEl = cards[i].querySelector('[class*=title]');
39+
var timeEl = cards[i].querySelector('[class*=tips]');
40+
var title = titleEl ? titleEl.textContent.trim() : '';
41+
var time = timeEl ? timeEl.textContent.trim().replace(/\\s+/g, ' ') : '';
42+
if (title) results.push({ Index: ++idx, Title: title, Time: time });
43+
}
44+
if (results.length > 0) return results;
45+
46+
var rows = document.querySelectorAll('tr, [class*=appmsg_item], [class*=list_item]');
47+
rows.forEach(function(row) {
48+
var titleEl = row.querySelector('[class*=title] a, [class*=title], h4');
49+
var timeEl = row.querySelector('[class*=time], td:nth-child(2)');
50+
var title = titleEl ? titleEl.textContent.trim() : '';
51+
var time = timeEl ? timeEl.textContent.trim() : '';
52+
if (title && title !== '内容' && title.length < 80) {
53+
results.push({ Index: ++idx, Title: title, Time: time });
54+
}
55+
});
56+
return results;
57+
})()`);
58+
59+
if (!drafts || drafts.length === 0) {
60+
throw new EmptyResultError('weixin drafts', 'No structured drafts found in the current Weixin Official Account backend');
61+
}
62+
63+
return drafts.slice(0, kwargs.limit);
64+
},
65+
});

0 commit comments

Comments
 (0)