create-draft.js
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 });