9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
227 lines
8.8 KiB
JavaScript
227 lines
8.8 KiB
JavaScript
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
|
|
const WEIXIN_DOMAIN = 'mp.weixin.qq.com';
|
|
const WEIXIN_HOME = 'https://mp.weixin.qq.com/';
|
|
|
|
async function getToken(page) {
|
|
return page.evaluate(`(window.location.href.match(/token=(\\d+)/)||[])[1]`);
|
|
}
|
|
|
|
async function navigateToEditor(page) {
|
|
await page.goto(WEIXIN_HOME);
|
|
await page.wait(3);
|
|
const token = await getToken(page);
|
|
if (!token) {
|
|
throw new CommandExecutionError('Could not extract session token. Please log in to mp.weixin.qq.com');
|
|
}
|
|
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`);
|
|
await page.wait(4);
|
|
const hasTitle = await page.evaluate('!!document.querySelector("textarea#title")');
|
|
if (!hasTitle) {
|
|
throw new CommandExecutionError('Article editor did not load. Session may have expired');
|
|
}
|
|
}
|
|
|
|
async function fillField(page, selector, value) {
|
|
return page.evaluate(`(() => {
|
|
var el = document.querySelector('${selector}');
|
|
if (!el) return { ok: false, reason: 'not found: ${selector}' };
|
|
el.focus();
|
|
var proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
if (setter && setter.set) setter.set.call(el, ${JSON.stringify(value)});
|
|
else el.value = ${JSON.stringify(value)};
|
|
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ${JSON.stringify(value)} }));
|
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
el.blur();
|
|
return { ok: true };
|
|
})()`);
|
|
}
|
|
|
|
async function fillContent(page, text) {
|
|
return page.evaluate(`(() => {
|
|
var editors = document.querySelectorAll('div[contenteditable="true"]');
|
|
var editor = editors[editors.length - 1];
|
|
if (!editor) return { ok: false, reason: 'content editor not found' };
|
|
editor.focus();
|
|
if (editor.querySelector('[contenteditable="false"]')) editor.innerHTML = '';
|
|
document.execCommand('selectAll', false, null);
|
|
document.execCommand('insertText', false, ${JSON.stringify(text)});
|
|
editor.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
|
return { ok: true };
|
|
})()`);
|
|
}
|
|
|
|
async function uploadContentImage(page, imagePath) {
|
|
const fs = await import('node:fs');
|
|
const path = await import('node:path');
|
|
const absPath = path.default.resolve(imagePath);
|
|
if (!fs.default.existsSync(absPath)) {
|
|
throw new CommandExecutionError(`Image not found: ${absPath}`);
|
|
}
|
|
if (!page.setFileInput) {
|
|
throw new CommandExecutionError('Image upload requires Browser Bridge with CDP support');
|
|
}
|
|
|
|
await page.evaluate(`(() => {
|
|
var li = document.querySelector('#js_editor_insertimage');
|
|
if (li) li.click();
|
|
})()`);
|
|
await page.wait(1);
|
|
await page.evaluate(`(() => {
|
|
var items = document.querySelectorAll('.js_img_dropdown_menu .tpl_dropdown_menu_item');
|
|
if (items[0]) items[0].click();
|
|
})()`);
|
|
await page.wait(1);
|
|
|
|
await page.setFileInput([absPath], 'input[type="file"][name="file"]');
|
|
await page.wait(8);
|
|
|
|
const cdnCount = await page.evaluate(`(() => {
|
|
var editor = document.querySelector('#ueditor_0');
|
|
return editor ? editor.querySelectorAll('img[src*="mmbiz"]').length : 0;
|
|
})()`);
|
|
if (cdnCount === 0) {
|
|
throw new CommandExecutionError('Image did not upload to WeChat CDN');
|
|
}
|
|
}
|
|
|
|
async function selectCoverFromContent(page) {
|
|
await page.evaluate('document.querySelector("#js_cover_description_area")?.scrollIntoView()');
|
|
await page.wait(1);
|
|
|
|
await page.evaluate('document.querySelector(".js_cover_btn_area")?.click()');
|
|
await page.wait(1);
|
|
|
|
await page.evaluate(`(() => {
|
|
var links = document.querySelectorAll('a.pop-opr__button');
|
|
for (var i = 0; i < links.length; i++) {
|
|
if (links[i].textContent.trim() === '从正文选择') { links[i].click(); return; }
|
|
}
|
|
})()`);
|
|
await page.wait(2);
|
|
|
|
await page.evaluate(`(() => {
|
|
var img = document.querySelector('.weui-desktop-dialog_img-picker .appmsg_content_img');
|
|
if (img) img.click();
|
|
})()`);
|
|
await page.wait(1);
|
|
|
|
await page.evaluate(`(() => {
|
|
var btns = document.querySelectorAll('.weui-desktop-dialog_img-picker button');
|
|
for (var i = 0; i < btns.length; i++) {
|
|
if (btns[i].textContent.trim() === '下一步' && !btns[i].disabled) { btns[i].click(); return; }
|
|
}
|
|
})()`);
|
|
|
|
// Crop dialog image rendering can be slow
|
|
for (let attempt = 0; attempt < 8; attempt++) {
|
|
await page.wait(2);
|
|
const ready = await page.evaluate(`(() => {
|
|
var btns = document.querySelectorAll('button');
|
|
for (var i = 0; i < btns.length; i++) {
|
|
if (btns[i].textContent.trim() === '确认' && btns[i].offsetHeight > 0 && !btns[i].disabled) return true;
|
|
}
|
|
return false;
|
|
})()`);
|
|
if (ready) break;
|
|
}
|
|
|
|
await page.evaluate(`(() => {
|
|
var btns = document.querySelectorAll('button');
|
|
for (var i = 0; i < btns.length; i++) {
|
|
if (btns[i].textContent.trim() === '确认' && btns[i].offsetHeight > 0 && !btns[i].disabled) { btns[i].click(); return; }
|
|
}
|
|
})()`);
|
|
await page.wait(2);
|
|
const hasCover = await page.evaluate(`(() => {
|
|
var area = document.querySelector('#js_cover_area');
|
|
if (!area) return false;
|
|
var found = false;
|
|
area.querySelectorAll('*').forEach(function(el) {
|
|
var bg = window.getComputedStyle(el).backgroundImage;
|
|
if (bg && bg.includes('mmbiz')) found = true;
|
|
});
|
|
return found;
|
|
})()`);
|
|
return hasCover;
|
|
}
|
|
|
|
async function clickSaveDraft(page) {
|
|
const result = await page.evaluate(`(() => {
|
|
var btns = document.querySelectorAll('span, button, a');
|
|
for (var i = 0; i < btns.length; i++) {
|
|
if ((btns[i].textContent || '').trim() === '保存为草稿') { btns[i].click(); return { ok: true }; }
|
|
}
|
|
return { ok: false };
|
|
})()`);
|
|
if (!result?.ok) throw new CommandExecutionError('Save draft button not found');
|
|
|
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
await page.wait(2);
|
|
const saved = await page.evaluate(`(() => {
|
|
var el = document.querySelector('#js_save_success');
|
|
if (el && window.getComputedStyle(el).display !== 'none') return true;
|
|
return document.body.innerText.includes('已保存');
|
|
})()`);
|
|
if (saved) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export const createDraftCommand = cli({
|
|
site: 'weixin',
|
|
name: 'create-draft',
|
|
access: 'write',
|
|
description: '创建微信公众号图文草稿',
|
|
domain: WEIXIN_DOMAIN,
|
|
strategy: Strategy.COOKIE,
|
|
browser: true,
|
|
navigateBefore: false,
|
|
args: [
|
|
{ name: 'title', required: true, help: '文章标题 (最长64字)' },
|
|
{ name: 'content', required: true, positional: true, help: '文章正文' },
|
|
{ name: 'author', help: '作者名 (最长8字)' },
|
|
{ name: 'cover-image', help: '封面图片路径 (会先上传到正文再设为封面)' },
|
|
{ name: 'summary', help: '文章摘要' },
|
|
{ name: 'timeout', type: 'int', required: false, default: 180, help: 'Max seconds for the overall command (default: 180)' },
|
|
],
|
|
columns: ['status', 'detail'],
|
|
|
|
func: async (page, kwargs) => {
|
|
await navigateToEditor(page);
|
|
|
|
const titleResult = await fillField(page, 'textarea#title', kwargs.title);
|
|
if (!titleResult?.ok) throw new CommandExecutionError('Failed to fill title');
|
|
|
|
if (kwargs.author) {
|
|
const authorResult = await fillField(page, 'input#author', kwargs.author);
|
|
if (!authorResult?.ok) throw new CommandExecutionError('Failed to fill author');
|
|
}
|
|
|
|
const contentResult = await fillContent(page, kwargs.content);
|
|
if (!contentResult?.ok) throw new CommandExecutionError('Failed to fill content');
|
|
|
|
if (kwargs['cover-image']) {
|
|
await uploadContentImage(page, kwargs['cover-image']);
|
|
const coverSet = await selectCoverFromContent(page);
|
|
if (!coverSet) {
|
|
// Non-fatal: draft can be saved without cover
|
|
}
|
|
}
|
|
|
|
if (kwargs.summary) {
|
|
await fillField(page, 'textarea#js_description', kwargs.summary);
|
|
}
|
|
|
|
await page.wait(1);
|
|
const success = await clickSaveDraft(page);
|
|
|
|
return [{
|
|
status: success ? 'draft saved' : 'save attempted, check browser to confirm',
|
|
detail: `"${kwargs.title}"${kwargs.author ? ` by ${kwargs.author}` : ''}${kwargs['cover-image'] ? ' (with cover)' : ''}`,
|
|
}];
|
|
},
|
|
});
|