import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import process from 'node:process'; import { launchChrome, getPageSession, waitForNewTab, clickElement, typeText, evaluate, sleep, type ChromeSession, type CdpConnection } from './cdp.ts'; const WECHAT_URL = 'https://mp.weixin.qq.com/'; interface ImageInfo { placeholder: string; localPath: string; originalPath: string; } interface ArticleOptions { title: string; content?: string; htmlFile?: string; markdownFile?: string; theme?: string; author?: string; summary?: string; images?: string[]; contentImages?: ImageInfo[]; submit?: boolean; profileDir?: string; } async function waitForLogin(session: ChromeSession, timeoutMs = 120_000): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { const url = await evaluate(session, 'window.location.href'); if (url.includes('/cgi-bin/home')) return true; await sleep(2000); } return false; } async function clickMenuByText(session: ChromeSession, text: string): Promise { console.log(`[wechat] Clicking "${text}" menu...`); const posResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', { expression: ` (function() { const items = document.querySelectorAll('.new-creation__menu .new-creation__menu-item'); for (const item of items) { const title = item.querySelector('.new-creation__menu-title'); if (title && title.textContent?.trim() === '${text}') { item.scrollIntoView({ block: 'center' }); const rect = item.getBoundingClientRect(); return JSON.stringify({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }); } } return 'null'; })() `, returnByValue: true, }, { sessionId: session.sessionId }); if (posResult.result.value === 'null') throw new Error(`Menu "${text}" not found`); const pos = JSON.parse(posResult.result.value); await session.cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId }); await sleep(100); await session.cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId }); } async function copyImageToClipboard(imagePath: string): Promise { const scriptDir = path.dirname(new URL(import.meta.url).pathname); const copyScript = path.join(scriptDir, './copy-to-clipboard.ts'); const result = spawnSync('npx', ['-y', 'bun', copyScript, 'image', imagePath], { stdio: 'inherit' }); if (result.status !== 0) throw new Error(`Failed to copy image: ${imagePath}`); } async function pasteInEditor(session: ChromeSession): Promise { const modifiers = process.platform === 'darwin' ? 4 : 2; await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId }); await sleep(50); await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId }); } async function copyHtmlFromBrowser(cdp: CdpConnection, htmlFilePath: string): Promise { const absolutePath = path.isAbsolute(htmlFilePath) ? htmlFilePath : path.resolve(process.cwd(), htmlFilePath); const fileUrl = `file://${absolutePath}`; console.log(`[wechat] Opening HTML file in new tab: ${fileUrl}`); const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: fileUrl }); const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId, flatten: true }); await cdp.send('Page.enable', {}, { sessionId }); await cdp.send('Runtime.enable', {}, { sessionId }); await sleep(2000); console.log('[wechat] Selecting #output content...'); await cdp.send<{ result: { value: unknown } }>('Runtime.evaluate', { expression: ` (function() { const output = document.querySelector('#output') || document.body; const range = document.createRange(); range.selectNodeContents(output); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); return true; })() `, returnByValue: true, }, { sessionId }); await sleep(300); console.log('[wechat] Copying with system Cmd+C...'); if (process.platform === 'darwin') { spawnSync('osascript', ['-e', 'tell application "System Events" to keystroke "c" using command down']); } else { spawnSync('xdotool', ['key', 'ctrl+c']); } await sleep(1000); console.log('[wechat] Closing HTML tab...'); await cdp.send('Target.closeTarget', { targetId }); } async function pasteFromClipboardInEditor(): Promise { if (process.platform === 'darwin') { spawnSync('osascript', ['-e', 'tell application "System Events" to keystroke "v" using command down']); } else { spawnSync('xdotool', ['key', 'ctrl+v']); } await sleep(1000); } async function parseMarkdownWithPlaceholders(markdownPath: string, theme?: string): Promise<{ title: string; author: string; summary: string; htmlPath: string; contentImages: ImageInfo[] }> { const scriptDir = path.dirname(new URL(import.meta.url).pathname); const mdToWechatScript = path.join(scriptDir, 'md-to-wechat.ts'); const args = ['-y', 'bun', mdToWechatScript, markdownPath]; if (theme) args.push('--theme', theme); const result = spawnSync('npx', args, { stdio: ['inherit', 'pipe', 'pipe'] }); if (result.status !== 0) { const stderr = result.stderr?.toString() || ''; throw new Error(`Failed to parse markdown: ${stderr}`); } const output = result.stdout.toString(); return JSON.parse(output); } function parseHtmlMeta(htmlPath: string): { title: string; author: string; summary: string } { const content = fs.readFileSync(htmlPath, 'utf-8'); let title = ''; const titleMatch = content.match(/([^<]+)<\/title>/i); if (titleMatch) title = titleMatch[1]!; let author = ''; const authorMatch = content.match(/<meta\s+name=["']author["']\s+content=["']([^"']+)["']/i); if (authorMatch) author = authorMatch[1]!; let summary = ''; const descMatch = content.match(/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i); if (descMatch) summary = descMatch[1]!; if (!summary) { const firstPMatch = content.match(/<p[^>]*>([^<]+)<\/p>/i); if (firstPMatch) { const text = firstPMatch[1]!.replace(/<[^>]+>/g, '').trim(); if (text.length > 20) { summary = text.length > 120 ? text.slice(0, 117) + '...' : text; } } } return { title, author, summary }; } async function selectAndReplacePlaceholder(session: ChromeSession, placeholder: string): Promise<boolean> { const result = await session.cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', { expression: ` (function() { const editor = document.querySelector('.ProseMirror'); if (!editor) return false; const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false); let node; while ((node = walker.nextNode())) { const text = node.textContent || ''; const idx = text.indexOf(${JSON.stringify(placeholder)}); if (idx !== -1) { node.parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); const range = document.createRange(); range.setStart(node, idx); range.setEnd(node, idx + ${placeholder.length}); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); return true; } } return false; })() `, returnByValue: true, }, { sessionId: session.sessionId }); return result.result.value; } async function pressDeleteKey(session: ChromeSession): Promise<void> { await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId: session.sessionId }); await sleep(50); await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId: session.sessionId }); } export async function postArticle(options: ArticleOptions): Promise<void> { const { title, content, htmlFile, markdownFile, theme, author, summary, images = [], submit = false, profileDir } = options; let { contentImages = [] } = options; let effectiveTitle = title || ''; let effectiveAuthor = author || ''; let effectiveSummary = summary || ''; let effectiveHtmlFile = htmlFile; if (markdownFile) { console.log(`[wechat] Parsing markdown: ${markdownFile}`); const parsed = await parseMarkdownWithPlaceholders(markdownFile, theme); effectiveTitle = effectiveTitle || parsed.title; effectiveAuthor = effectiveAuthor || parsed.author; effectiveSummary = effectiveSummary || parsed.summary; effectiveHtmlFile = parsed.htmlPath; contentImages = parsed.contentImages; console.log(`[wechat] Title: ${effectiveTitle || '(empty)'}`); console.log(`[wechat] Author: ${effectiveAuthor || '(empty)'}`); console.log(`[wechat] Summary: ${effectiveSummary || '(empty)'}`); console.log(`[wechat] Found ${contentImages.length} images to insert`); } else if (htmlFile && fs.existsSync(htmlFile)) { console.log(`[wechat] Parsing HTML: ${htmlFile}`); const meta = parseHtmlMeta(htmlFile); effectiveTitle = effectiveTitle || meta.title; effectiveAuthor = effectiveAuthor || meta.author; effectiveSummary = effectiveSummary || meta.summary; effectiveHtmlFile = htmlFile; console.log(`[wechat] Title: ${effectiveTitle || '(empty)'}`); console.log(`[wechat] Author: ${effectiveAuthor || '(empty)'}`); console.log(`[wechat] Summary: ${effectiveSummary || '(empty)'}`); } if (effectiveTitle && effectiveTitle.length > 64) throw new Error(`Title too long: ${effectiveTitle.length} chars (max 64)`); if (!content && !effectiveHtmlFile) throw new Error('Either --content, --html, or --markdown is required'); const { cdp, chrome } = await launchChrome(WECHAT_URL, profileDir); try { console.log('[wechat] Waiting for page load...'); await sleep(3000); let session = await getPageSession(cdp, 'mp.weixin.qq.com'); const url = await evaluate<string>(session, 'window.location.href'); if (!url.includes('/cgi-bin/home')) { console.log('[wechat] Not logged in. Please scan QR code...'); const loggedIn = await waitForLogin(session); if (!loggedIn) throw new Error('Login timeout'); } console.log('[wechat] Logged in.'); await sleep(2000); const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets'); const initialIds = new Set(targets.targetInfos.map(t => t.targetId)); await clickMenuByText(session, '文章'); await sleep(3000); const editorTargetId = await waitForNewTab(cdp, initialIds, 'mp.weixin.qq.com'); console.log('[wechat] Editor tab opened.'); const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: editorTargetId, flatten: true }); session = { cdp, sessionId, targetId: editorTargetId }; await cdp.send('Page.enable', {}, { sessionId }); await cdp.send('Runtime.enable', {}, { sessionId }); await cdp.send('DOM.enable', {}, { sessionId }); await sleep(3000); if (effectiveTitle) { console.log('[wechat] Filling title...'); await evaluate(session, `document.querySelector('#title').value = ${JSON.stringify(effectiveTitle)}; document.querySelector('#title').dispatchEvent(new Event('input', { bubbles: true }));`); } if (effectiveAuthor) { console.log('[wechat] Filling author...'); await evaluate(session, `document.querySelector('#author').value = ${JSON.stringify(effectiveAuthor)}; document.querySelector('#author').dispatchEvent(new Event('input', { bubbles: true }));`); } console.log('[wechat] Clicking on editor...'); await clickElement(session, '.ProseMirror'); await sleep(500); if (effectiveHtmlFile && fs.existsSync(effectiveHtmlFile)) { console.log(`[wechat] Copying HTML content from: ${effectiveHtmlFile}`); await copyHtmlFromBrowser(cdp, effectiveHtmlFile); await sleep(500); console.log('[wechat] Pasting into editor...'); await pasteFromClipboardInEditor(); await sleep(3000); if (contentImages.length > 0) { console.log(`[wechat] Inserting ${contentImages.length} images...`); for (let i = 0; i < contentImages.length; i++) { const img = contentImages[i]!; console.log(`[wechat] [${i + 1}/${contentImages.length}] Processing: ${img.placeholder}`); const found = await selectAndReplacePlaceholder(session, img.placeholder); if (!found) { console.warn(`[wechat] Placeholder not found: ${img.placeholder}`); continue; } await sleep(500); console.log(`[wechat] Copying image: ${path.basename(img.localPath)}`); await copyImageToClipboard(img.localPath); await sleep(300); console.log('[wechat] Deleting placeholder with Backspace...'); await pressDeleteKey(session); await sleep(200); console.log('[wechat] Pasting image...'); await pasteFromClipboardInEditor(); await sleep(3000); } console.log('[wechat] All images inserted.'); } } else if (content) { for (const img of images) { if (fs.existsSync(img)) { console.log(`[wechat] Pasting image: ${img}`); await copyImageToClipboard(img); await sleep(500); await pasteInEditor(session); await sleep(2000); } } console.log('[wechat] Typing content...'); await typeText(session, content); await sleep(1000); } if (effectiveSummary) { console.log(`[wechat] Filling summary: ${effectiveSummary}`); await evaluate(session, `document.querySelector('#js_description').value = ${JSON.stringify(effectiveSummary)}; document.querySelector('#js_description').dispatchEvent(new Event('input', { bubbles: true }));`); } console.log('[wechat] Saving as draft...'); await evaluate(session, `document.querySelector('#js_submit button').click()`); await sleep(3000); const saved = await evaluate<boolean>(session, `!!document.querySelector('.weui-desktop-toast')`); if (saved) { console.log('[wechat] Draft saved successfully!'); } else { console.log('[wechat] Waiting for save confirmation...'); await sleep(5000); } console.log('[wechat] Done. Browser window left open.'); } finally { cdp.close(); } } function printUsage(): never { console.log(`Post article to WeChat Official Account Usage: npx -y bun wechat-article.ts [options] Options: --title <text> Article title (auto-extracted from markdown) --content <text> Article content (use with --image) --html <path> HTML file to paste (alternative to --content) --markdown <path> Markdown file to convert and post (recommended) --theme <name> Theme for markdown (default, grace, simple) --author <name> Author name (default: 宝玉) --summary <text> Article summary --image <path> Content image, can repeat (only with --content) --submit Save as draft --profile <dir> Chrome profile directory Examples: npx -y bun wechat-article.ts --markdown article.md npx -y bun wechat-article.ts --markdown article.md --theme grace --submit npx -y bun wechat-article.ts --title "标题" --content "内容" --image img.png npx -y bun wechat-article.ts --title "标题" --html article.html --submit Markdown mode: Images in markdown are converted to placeholders. After pasting HTML, each placeholder is selected, scrolled into view, deleted, and replaced with the actual image via paste. `); process.exit(0); } async function main(): Promise<void> { const args = process.argv.slice(2); if (args.includes('--help') || args.includes('-h')) printUsage(); const images: string[] = []; let title: string | undefined; let content: string | undefined; let htmlFile: string | undefined; let markdownFile: string | undefined; let theme: string | undefined; let author: string | undefined; let summary: string | undefined; let submit = false; let profileDir: string | undefined; for (let i = 0; i < args.length; i++) { const arg = args[i]!; if (arg === '--title' && args[i + 1]) title = args[++i]; else if (arg === '--content' && args[i + 1]) content = args[++i]; else if (arg === '--html' && args[i + 1]) htmlFile = args[++i]; else if (arg === '--markdown' && args[i + 1]) markdownFile = args[++i]; else if (arg === '--theme' && args[i + 1]) theme = args[++i]; else if (arg === '--author' && args[i + 1]) author = args[++i]; else if (arg === '--summary' && args[i + 1]) summary = args[++i]; else if (arg === '--image' && args[i + 1]) images.push(args[++i]!); else if (arg === '--submit') submit = true; else if (arg === '--profile' && args[i + 1]) profileDir = args[++i]; } if (!markdownFile && !htmlFile && !title) { console.error('Error: --title is required (or use --markdown/--html)'); process.exit(1); } if (!markdownFile && !htmlFile && !content) { console.error('Error: --content, --html, or --markdown is required'); process.exit(1); } await postArticle({ title: title || '', content, htmlFile, markdownFile, theme, author, summary, images, submit, profileDir }); } await main().catch((err) => { console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); });