diff --git a/README.md b/README.md index efcfb4bc4..bfe3b727c 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,7 @@ When the site you need is not yet covered, use the `opencli-adapter-author` skil | **xiaohongshu** | `search` `ask` `note` `comments` `feed` `user` `download` `publish` `follow` `unfollow` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` | | **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `follow` `unfollow` `me` `subtitle` `summary` `video` `user-videos` | | **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` | +| **weixin** | `search` `download` `drafts` `create-draft` `create-sticker` | | **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | | **hltv** | `search` `player-summary` `player-matches` `player-form` `player-map-pool` `player-vs-team` `player-teammate-impact` `player-duel` `match-map` `match-series` `team-matches` `team-map-pool` `event-matches` | | **geogebra** | `eval` `add-point` `add-line` `add-circle` `add-polygon` `triangle` `hexagon` `list` `info` | diff --git a/README.zh-CN.md b/README.zh-CN.md index 885acfdfd..fd92f0ead 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -175,6 +175,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自 | **xiaohongshu** | `search` `ask` `note` `comments` `notifications` `feed` `user` `saved` `liked` `download` `publish` `follow` `unfollow` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | | **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `summary` `video` `comments` `dynamic` `ranking` `following` `follow` `unfollow` `user-videos` `download` | | **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` | +| **weixin** | `search` `download` `drafts` `create-draft` `create-sticker` | | **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | | **hltv** | `search` `player-summary` `player-matches` `player-form` `player-map-pool` `player-vs-team` `player-teammate-impact` `player-duel` `match-map` `match-series` `team-matches` `team-map-pool` `event-matches` | | **geogebra** | `eval` `add-point` `add-line` `add-circle` `add-polygon` `triangle` `hexagon` `list` `info` | diff --git a/cli-manifest.json b/cli-manifest.json index 58a0bb0d0..e53288ce4 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -39507,6 +39507,51 @@ "sourceFile": "weixin/create-draft.js", "navigateBefore": false }, + { + "site": "weixin", + "name": "create-sticker", + "description": "创建微信公众号贴图草稿", + "access": "write", + "domain": "mp.weixin.qq.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "content", + "type": "str", + "required": false, + "positional": true, + "help": "贴图描述 (最多1000字)" + }, + { + "name": "image", + "type": "str", + "required": true, + "help": "贴图图片路径 (bmp/gif/jpg/png/webp)" + }, + { + "name": "title", + "type": "str", + "required": false, + "help": "贴图标题 (最多20字,选填)" + }, + { + "name": "timeout", + "type": "int", + "default": 180, + "required": false, + "help": "Max seconds for the overall command (default: 180)" + } + ], + "columns": [ + "status", + "detail" + ], + "type": "js", + "modulePath": "weixin/create-sticker.js", + "sourceFile": "weixin/create-sticker.js", + "navigateBefore": false + }, { "site": "weixin", "name": "download", diff --git a/clis/weixin/create-sticker.js b/clis/weixin/create-sticker.js new file mode 100644 index 000000000..7dca66b26 --- /dev/null +++ b/clis/weixin/create-sticker.js @@ -0,0 +1,259 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +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/'; +const MIME_TYPES = { + '.bmp': 'image/bmp', + '.gif': 'image/gif', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp', +}; + +async function getToken(page) { + return page.evaluate(`(window.location.href.match(/token=(\\d+)/)||[])[1]`); +} + +async function navigateToStickerEditor(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&createType=8&token=${token}&lang=zh_CN`); + await page.wait(4); + const ready = await page.evaluate(`!!document.querySelector('.image-selector')`); + if (!ready) { + throw new CommandExecutionError('Sticker editor did not load. Session may have expired'); + } +} + +async function fillTitle(page, title) { + if (!title) return true; + const result = await page.evaluate(`(() => { + var title = ${JSON.stringify(title)}; + var el = document.querySelector('textarea#title, #title, .js_title'); + if (el) { + el.focus(); + var proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + var setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; + if (setter) setter.call(el, title); + else el.value = title; + el.dispatchEvent(new InputEvent('input', { bubbles: true, data: title })); + el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + el.dispatchEvent(new Event('blur', { bubbles: true })); + el.blur(); + } + + if (window.wx && window.wx.ueditor && window.wx.ueditor.titleEditor) { + window.wx.ueditor.titleEditor.setContent(title); + } + if (window.wx && window.wx.ueditor && typeof window.wx.ueditor.fireEvent === 'function') { + window.wx.ueditor.fireEvent('setCurrentAritleTitle', title); + window.wx.ueditor.fireEvent('updateTitleStatus', { title }); + } + + var titlePlace = document.querySelector('.js_title_place'); + if (titlePlace) titlePlace.textContent = title; + var parent = document.querySelector('#js_content_top')?.__vue__ || document.querySelector('.image-selector')?.__vue__?.$parent; + if (parent && parent.articleData) { + parent.articleData.title = title; + parent.articleData.is_user_title = 1; + } + return { ok: !!(el || (window.wx && window.wx.ueditor && window.wx.ueditor.titleEditor) || (parent && parent.articleData)) }; + })()`); + return result === true || result?.ok === true; +} + +async function fillDescription(page, text) { + if (!text) return true; + return page.evaluate(`(() => { + var editor = document.querySelector('.share-text__input .ProseMirror'); + if (!editor) return false; + editor.focus(); + document.execCommand('selectAll', false, null); + document.execCommand('insertText', false, ${JSON.stringify(text)}); + editor.dispatchEvent(new InputEvent('input', { bubbles: true, data: ${JSON.stringify(text)} })); + return true; + })()`); +} + +function readStickerImage(imagePath) { + const absPath = path.resolve(imagePath); + if (!fs.existsSync(absPath)) { + throw new CommandExecutionError(`Image not found: ${absPath}`); + } + const ext = path.extname(absPath).toLowerCase(); + const mime = MIME_TYPES[ext]; + if (!mime) { + throw new CommandExecutionError(`Unsupported sticker image format "${ext}". Supported: ${Object.keys(MIME_TYPES).join(', ')}`); + } + + return { + name: path.basename(absPath), + mime, + base64: fs.readFileSync(absPath).toString('base64'), + }; +} + +async function uploadStickerImageFile(page, image) { + return page.evaluate(` + (async img => { + const data = (window.wx && window.wx.commonData && window.wx.commonData.data) + || (window.wx && window.wx.data) + || {}; + const selector = document.querySelector('.image-selector'); + const vm = selector && selector.__vue__; + const query = (vm && vm.uploadQuery) || { scene: 5, writetype: 'doublewrite', groupid: 1 }; + if (!data.user_name || !data.ticket || !data.time) { + return { ok: false, error: 'WeChat upload ticket is unavailable' }; + } + + const params = new URLSearchParams({ + ticket_id: data.user_name, + ticket: data.ticket, + svr_time: data.time, + }); + Object.keys(query).forEach(key => params.set(key, query[key])); + const url = new URL('/cgi-bin/filetransfer?action=upload_material&f=json&' + params.toString(), location.origin).toString(); + + const binary = atob(img.base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + const file = new File([new Blob([bytes], { type: img.mime })], img.name, { type: img.mime }); + const form = new FormData(); + form.append('file', file); + + const response = await fetch(url, { method: 'POST', body: form, credentials: 'include' }); + const text = await response.text(); + let json = null; + try { json = JSON.parse(text); } catch (_) {} + if (!response.ok) { + return { ok: false, error: 'HTTP ' + response.status, body: text.slice(0, 500) }; + } + if (!json || !json.base_resp || json.base_resp.ret !== 0) { + return { + ok: false, + error: (json && json.base_resp && (json.base_resp.err_msg || json.base_resp.ret)) || 'unknown upload error', + response: json, + }; + } + if (!json.content || !json.cdn_url) { + return { ok: false, error: 'upload response missing file id or cdn url', response: json }; + } + + if (window.wx && typeof window.wx.getSeq === 'function') { + fetch('/cgi-bin/modifyfile?oper=updaterecent&fileid=' + encodeURIComponent(json.content) + '&seq=' + encodeURIComponent(window.wx.getSeq()), { + credentials: 'include', + }).catch(() => {}); + } + return { ok: true, fileId: Number(json.content), cdnUrl: json.cdn_url }; + })(${JSON.stringify(image)}) + `); +} + +async function attachStickerImage(page, uploaded) { + return page.evaluate(` + (async uploaded => { + const selector = document.querySelector('.image-selector'); + const vm = selector && selector.__vue__; + if (!vm) return { ok: false, error: 'sticker image selector not found' }; + + vm.innerList = [{ + file_id: uploaded.fileId, + cdn_url: uploaded.cdnUrl, + url: uploaded.cdnUrl, + loading: false, + }]; + if (typeof vm.formatList === 'function') await vm.formatList(); + await new Promise(resolve => vm.$nextTick(resolve)); + if (vm.innerList.length) vm.selected = vm.innerList[vm.innerList.length - 1].seq; + if (typeof vm.onChange === 'function') vm.onChange(); + if (typeof vm.updateRecommendTopic === 'function') vm.updateRecommendTopic(); + await new Promise(resolve => vm.$nextTick(resolve)); + + return { + ok: vm.innerList.length > 0 && typeof vm.innerList[0].file_id === 'number', + }; + })(${JSON.stringify(uploaded)}) + `); +} + +async function uploadStickerImage(page, imagePath) { + const image = readStickerImage(imagePath); + const uploaded = await uploadStickerImageFile(page, image); + if (!uploaded?.ok) { + throw new CommandExecutionError(`Sticker image upload failed: ${uploaded?.error || 'unknown'}`); + } + + const attached = await attachStickerImage(page, uploaded); + if (!attached?.ok) { + throw new CommandExecutionError(`Sticker image did not attach to editor: ${attached?.error || 'unknown'}`); + } +} + +async function clickSaveDraft(page) { + const result = await page.evaluate(`(() => { + var btn = document.querySelector('#js_submit button') || document.querySelector('#js_submit'); + if (btn) { btn.click(); return { ok: true }; } + var nodes = document.querySelectorAll('span, button, a'); + for (var i = 0; i < nodes.length; i++) { + if ((nodes[i].textContent || '').trim() === '保存为草稿') { nodes[i].click(); return { ok: true }; } + } + return { ok: false }; + })()`); + if (!result?.ok) throw new CommandExecutionError('Save sticker draft button not found'); + + for (let attempt = 0; attempt < 5; attempt++) { + await page.wait(2); + const saved = await page.evaluate(`(() => { + var text = document.body.innerText || ''; + return text.includes('已保存') || text.includes('保存成功') || !!document.querySelector('#js_save_success'); + })()`); + if (saved) return true; + } + return false; +} + +export const createStickerCommand = cli({ + site: 'weixin', + name: 'create-sticker', + access: 'write', + description: '创建微信公众号贴图草稿', + domain: WEIXIN_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'content', required: false, positional: true, help: '贴图描述 (最多1000字)' }, + { name: 'image', required: true, help: '贴图图片路径 (bmp/gif/jpg/png/webp)' }, + { name: 'title', required: false, help: '贴图标题 (最多20字,选填)' }, + { name: 'timeout', type: 'int', required: false, default: 180, help: 'Max seconds for the overall command (default: 180)' }, + ], + columns: ['status', 'detail'], + + func: async (page, kwargs) => { + const title = String(kwargs.title || '').trim(); + const content = String(kwargs.content || '').trim(); + if (!kwargs.image) throw new CommandExecutionError('--image is required'); + if (title.length > 20) throw new CommandExecutionError('Sticker title must be ≤ 20 chars'); + if (content.length > 1000) throw new CommandExecutionError('Sticker description must be ≤ 1000 chars'); + + await navigateToStickerEditor(page); + await uploadStickerImage(page, kwargs.image); + if (!(await fillTitle(page, title))) throw new CommandExecutionError('Failed to fill sticker title'); + if (!(await fillDescription(page, content))) throw new CommandExecutionError('Failed to fill sticker description'); + const success = await clickSaveDraft(page); + + return [{ + status: success ? 'sticker draft saved' : 'save attempted, check browser to confirm', + detail: `"${title || path.basename(kwargs.image)}" (sticker)`, + }]; + }, +}); diff --git a/clis/weixin/drafts.test.js b/clis/weixin/drafts.test.js index 1241869cb..0d204b8f3 100644 --- a/clis/weixin/drafts.test.js +++ b/clis/weixin/drafts.test.js @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors'; import { getRegistry } from '@jackwener/opencli/registry'; import './create-draft.js'; +import './create-sticker.js'; import './drafts.js'; import './search.js'; @@ -19,6 +20,7 @@ describe('weixin command registration', () => { const registry = getRegistry(); const values = [...registry.values()]; expect(values.find(c => c.site === 'weixin' && c.name === 'create-draft')).toBeDefined(); + expect(values.find(c => c.site === 'weixin' && c.name === 'create-sticker')).toBeDefined(); const draftsCommand = values.find(c => c.site === 'weixin' && c.name === 'drafts'); expect(draftsCommand).toBeDefined(); expect(draftsCommand.args.find((arg) => arg.name === 'timeout')).toMatchObject({ type: 'int', default: 60 }); @@ -26,6 +28,41 @@ describe('weixin command registration', () => { }); }); +describe('weixin create-sticker command', () => { + it('opens the sticker editor and saves a sticker draft', async () => { + const command = getRegistry().get('weixin/create-sticker'); + const imagePath = new URL('../../extension/icons/icon-16.png', import.meta.url).pathname; + const page = createPageMock({ + evaluate: vi.fn().mockImplementation(async (code) => { + if (code.includes('window.location.href.match')) return '123456'; + if (code.includes('filetransfer?action=upload_material')) return { + ok: true, + fileId: 456, + cdnUrl: 'https://mmbiz.qpic.cn/test.jpg', + }; + if (code.includes('vm.innerList')) return { ok: true }; + if (code.includes('!!document.querySelector') && code.includes('.image-selector')) return true; + if (code.includes('textarea#title')) return true; + if (code.includes('.share-text__input .ProseMirror')) return true; + if (code.includes('#js_submit')) return { ok: true }; + if (code.includes('保存成功')) return true; + return undefined; + }), + }); + + const result = await command.func(page, { + image: imagePath, + title: '贴图标题', + content: '贴图描述', + }); + + expect(page.goto).toHaveBeenCalledWith(expect.stringContaining('createType=8')); + expect(page.evaluate).toHaveBeenCalledWith(expect.stringContaining('filetransfer?action=upload_material')); + expect(page.setFileInput).not.toHaveBeenCalled(); + expect(result).toEqual([{ status: 'sticker draft saved', detail: '"贴图标题" (sticker)' }]); + }); +}); + describe('weixin drafts command', () => { it('throws AuthRequiredError when no session token is available', async () => { const command = getRegistry().get('weixin/drafts'); diff --git a/docs/adapters/browser/weixin.md b/docs/adapters/browser/weixin.md index 7a8bb2856..9591f2a92 100644 --- a/docs/adapters/browser/weixin.md +++ b/docs/adapters/browser/weixin.md @@ -10,6 +10,7 @@ | `opencli weixin download` | 下载微信公众号文章为 Markdown 格式 | | `opencli weixin drafts` | 列出公众号后台草稿箱中的图文草稿 | | `opencli weixin create-draft` | 在公众号后台创建新的图文草稿 | +| `opencli weixin create-sticker` | 在公众号后台创建新的贴图草稿 | ## Usage Examples @@ -37,6 +38,9 @@ opencli weixin create-draft --title "周报" --author "OpenCLI" --summary "本 # Create a draft with a cover image sourced from local disk opencli weixin create-draft --title "封面示例" --cover-image ./cover.png "正文会先插入图片,再设为封面" + +# Create a sticker draft +opencli weixin create-sticker --image ./sticker.png --title "贴图标题" "贴图描述" ``` ## Output diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 63874372f..d9b789445 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -70,7 +70,7 @@ Run `opencli list` for the live registry. | **[1688](./browser/1688.md)** | `search` `item` `assets` `download` `store` | 🔐 Browser | | **[gitee](./browser/gitee.md)** | `trending` `search` `user` | 🌐 / 🔐 | | **[web](./browser/web.md)** | `read` | 🔐 Browser | -| **[weixin](./browser/weixin.md)** | `search` `download` `drafts` `create-draft` | 🌐 / 🔐 | +| **[weixin](./browser/weixin.md)** | `search` `download` `drafts` `create-draft` `create-sticker` | 🌐 / 🔐 | | **[36kr](./browser/36kr.md)** | `news` `hot` `search` `article` | 🌐 / 🔐 | | **[uisdc](./browser/uisdc.md)** | `news` | 🌐 Public | | **[aibase](./browser/aibase.md)** | `news` | 🌐 Public |