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