Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Post articles and image-text content to WeChat Official Account via API or Chrome CDP, with markdown-to-WeChat HTML conversion.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/wechat-article.ts
1import fs from 'node:fs';2import os from 'node:os';3import path from 'node:path';4import { spawnSync } from 'node:child_process';5import process from 'node:process';6import { fileURLToPath } from 'node:url';7import { launchChrome, tryConnectExisting, findExistingChromeDebugPort, getPageSession, waitForNewTab, clickElement, typeText, evaluate, sleep, getAccountProfileDir, type ChromeSession, type CdpConnection } from './cdp.ts';8import { loadWechatExtendConfig, resolveAccount } from './wechat-extend-config.ts';9import { prepareWechatBodyImageUpload } from './wechat-image-processor.ts';1011const WECHAT_URL = 'https://mp.weixin.qq.com/';12const BODY_EDITOR_SELECTOR = '.rich_media_content .ProseMirror';1314interface ImageInfo {15placeholder: string;16localPath: string;17originalPath: string;18}1920interface ArticleOptions {21title: string;22content?: string;23htmlFile?: string;24markdownFile?: string;25theme?: string;26color?: string;27citeStatus?: boolean;28author?: string;29summary?: string;30images?: string[];31contentImages?: ImageInfo[];32submit?: boolean;33profileDir?: string;34cdpPort?: number;35}3637async function sendQrToTelegram(session: ChromeSession): Promise<void> {38const botToken = process.env.TELEGRAM_BOT_TOKEN;39const chatId = process.env.TELEGRAM_CHAT_ID;40if (!botToken || !chatId) return;4142// Wait for QR to render before extracting43await sleep(2000);4445try {46// Try to extract QR image from DOM first (avoids full-page screenshot noise)47const domResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {48expression: `49(function() {50const selectors = [51'.login__type__container__scan img',52'.login_img img',53'#login_container img',54'.qrcode img',55'img[src*="qrcode"]',56'img[src*="login"]',57];58for (const sel of selectors) {59const el = document.querySelector(sel);60if (el?.src && !el.src.startsWith('data:,')) return el.src.startsWith('data:') ? el.src : 'url:' + el.src;61}62const canvas = document.querySelector('canvas');63if (canvas) try { return canvas.toDataURL('image/png'); } catch {}64return '';65})()66`,67returnByValue: true,68}, { sessionId: session.sessionId });6970const raw = (domResult.result.value as string) ?? '';71let imgBuffer: Buffer;7273if (raw.startsWith('data:image')) {74imgBuffer = Buffer.from(raw.split(',')[1] ?? '', 'base64');75} else if (raw.startsWith('url:')) {76// Fetch inside Chrome to carry WeChat session cookies77const imgUrl = raw.slice(4);78const inBrowserFetch = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {79expression: `80(async () => {81const resp = await fetch(${JSON.stringify(imgUrl)}, { credentials: 'include' });82const buf = await resp.arrayBuffer();83const bytes = new Uint8Array(buf);84let b = '';85for (let i = 0; i < bytes.length; i++) b += String.fromCharCode(bytes[i]);86return btoa(b);87})()88`,89returnByValue: true,90awaitPromise: true,91}, { sessionId: session.sessionId });92imgBuffer = Buffer.from((inBrowserFetch.result.value as string) ?? '', 'base64');93} else {94// Fallback: viewport screenshot (smaller than full-page; QR is usually in viewport)95const screenshotResp = await session.cdp.send<{ data: string }>(96'Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }, { sessionId: session.sessionId }97);98imgBuffer = Buffer.from(screenshotResp.data ?? '', 'base64');99}100101const boundary = `tgboundary${Date.now()}`;102const parts: Buffer[] = [103Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="chat_id"\r\n\r\n${chatId}\r\n`),104Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="caption"\r\n\r\nWeChat QR code — please scan to log in\r\n`),105Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="photo"; filename="qr.png"\r\nContent-Type: image/png\r\n\r\n`),106imgBuffer,107Buffer.from(`\r\n--${boundary}--\r\n`),108];109const tgResp = await fetch(`https://api.telegram.org/bot${botToken}/sendPhoto`, {110method: 'POST',111headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` },112body: Buffer.concat(parts),113signal: AbortSignal.timeout(10_000),114});115const tgJson = await tgResp.json() as { ok: boolean; description?: string };116if (tgJson.ok) {117console.log('[wechat] QR code sent to Telegram.');118} else {119console.error('[wechat] Telegram send failed:', tgJson.description);120}121} catch (err) {122console.error('[wechat] Failed to send QR to Telegram:', err);123}124}125126async function waitForLogin(session: ChromeSession, timeoutMs = 120_000): Promise<boolean> {127// Notify via Telegram if configured (no-op when env vars absent)128await sendQrToTelegram(session);129const start = Date.now();130while (Date.now() - start < timeoutMs) {131const url = await evaluate<string>(session, 'window.location.href');132if (url.includes('/cgi-bin/home')) return true;133await sleep(2000);134}135return false;136}137138async function waitForElement(session: ChromeSession, selector: string, timeoutMs = 10_000): Promise<boolean> {139const start = Date.now();140while (Date.now() - start < timeoutMs) {141const found = await evaluate<boolean>(session, `!!document.querySelector('${selector}')`);142if (found) return true;143await sleep(500);144}145return false;146}147148async function clickMenuByText(session: ChromeSession, text: string, maxRetries = 5): Promise<void> {149console.log(`[wechat] Clicking "${text}" menu...`);150for (let attempt = 1; attempt <= maxRetries; attempt++) {151const posResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {152expression: `153(function() {154const items = document.querySelectorAll('.new-creation__menu .new-creation__menu-item');155for (const item of items) {156const title = item.querySelector('.new-creation__menu-title');157if (title && title.textContent?.trim() === '${text}') {158item.scrollIntoView({ block: 'center' });159const rect = item.getBoundingClientRect();160return JSON.stringify({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 });161}162}163return 'null';164})()165`,166returnByValue: true,167}, { sessionId: session.sessionId });168169if (posResult.result.value !== 'null') {170const pos = JSON.parse(posResult.result.value);171await session.cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });172await sleep(100);173await session.cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });174return;175}176177if (attempt < maxRetries) {178const delay = Math.min(1000 * attempt, 3000);179console.log(`[wechat] Menu "${text}" not found, retrying in ${delay}ms (${attempt}/${maxRetries})...`);180await sleep(delay);181}182}183throw new Error(`Menu "${text}" not found after ${maxRetries} attempts`);184}185186async function copyImageToClipboard(imagePath: string): Promise<void> {187const __filename = fileURLToPath(import.meta.url);188const __dirname = path.dirname(__filename);189const copyScript = path.join(__dirname, './copy-to-clipboard.ts');190const result = spawnSync('npx', ['-y', 'bun', copyScript, 'image', imagePath], { stdio: 'inherit' });191if (result.status !== 0) throw new Error(`Failed to copy image: ${imagePath}`);192}193194async function pasteInEditor(session: ChromeSession): Promise<void> {195const modifiers = process.platform === 'darwin' ? 4 : 2;196await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId });197await sleep(50);198await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId });199}200201async function sendCopy(cdp?: CdpConnection, sessionId?: string): Promise<void> {202if (cdp && sessionId) {203const modifiers = process.platform === 'darwin' ? 4 : 2;204await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'c', code: 'KeyC', modifiers, windowsVirtualKeyCode: 67 }, { sessionId });205await sleep(50);206await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'c', code: 'KeyC', modifiers, windowsVirtualKeyCode: 67 }, { sessionId });207} else if (process.platform === 'darwin') {208spawnSync('osascript', ['-e', 'tell application "System Events" to keystroke "c" using command down']);209} else if (process.platform === 'linux') {210spawnSync('xdotool', ['key', 'ctrl+c']);211}212}213214async function sendPaste(cdp?: CdpConnection, sessionId?: string): Promise<void> {215if (!cdp || !sessionId) {216throw new Error('Targeted paste requires a Chrome DevTools session');217}218219const modifiers = process.platform === 'darwin' ? 4 : 2;220await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId });221await sleep(50);222await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId });223}224225async function copyHtmlFromBrowser(cdp: CdpConnection, htmlFilePath: string, contentImages: ImageInfo[] = []): Promise<void> {226const absolutePath = path.isAbsolute(htmlFilePath) ? htmlFilePath : path.resolve(process.cwd(), htmlFilePath);227const fileUrl = `file://${absolutePath}`;228229console.log(`[wechat] Opening HTML file in new tab: ${fileUrl}`);230231const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: fileUrl });232const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId, flatten: true });233234await cdp.send('Page.enable', {}, { sessionId });235await cdp.send('Runtime.enable', {}, { sessionId });236await sleep(2000);237238if (contentImages.length > 0) {239console.log('[wechat] Replacing img tags with placeholders for browser paste...');240const replacements = contentImages.map(img => ({ placeholder: img.placeholder, localPath: img.localPath }));241await cdp.send<{ result: { value: unknown } }>('Runtime.evaluate', {242expression: `243(function() {244const replacements = ${JSON.stringify(replacements)};245for (const r of replacements) {246const imgs = document.querySelectorAll('img[src="' + r.placeholder + '"], img[data-local-path="' + r.localPath + '"]');247for (const img of imgs) {248const text = document.createTextNode(r.placeholder);249img.parentNode.replaceChild(text, img);250}251}252return true;253})()254`,255returnByValue: true,256}, { sessionId });257await sleep(500);258}259260console.log('[wechat] Selecting #output content...');261await cdp.send<{ result: { value: unknown } }>('Runtime.evaluate', {262expression: `263(function() {264const output = document.querySelector('#output') || document.body;265const range = document.createRange();266range.selectNodeContents(output);267const selection = window.getSelection();268selection.removeAllRanges();269selection.addRange(range);270return true;271})()272`,273returnByValue: true,274}, { sessionId });275await sleep(300);276277console.log('[wechat] Activating HTML tab for copy...');278await cdp.send('Target.activateTarget', { targetId });279await sleep(300);280281console.log('[wechat] Copying content...');282await sendCopy(cdp, sessionId);283await sleep(1000);284285console.log('[wechat] Closing HTML tab...');286await cdp.send('Target.closeTarget', { targetId });287}288289async function pasteFromClipboardInEditor(session: ChromeSession): Promise<void> {290console.log('[wechat] Activating editor tab for paste...');291if (session.targetId) {292await session.cdp.send('Target.activateTarget', { targetId: session.targetId });293await sleep(300);294}295console.log('[wechat] Pasting content...');296await sendPaste(session.cdp, session.sessionId);297await sleep(1000);298}299300async function insertHtmlIntoEditorFromFile(301session: ChromeSession,302htmlFilePath: string,303contentImages: ImageInfo[] = [],304): Promise<void> {305const absolutePath = path.isAbsolute(htmlFilePath) ? htmlFilePath : path.resolve(process.cwd(), htmlFilePath);306const html = fs.readFileSync(absolutePath, 'utf8');307const replacements = contentImages.map(img => ({ placeholder: img.placeholder, localPath: img.localPath }));308309const result = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {310expression: `311(function() {312const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});313if (!editor) return JSON.stringify({ ok: false, reason: 'editor-missing' });314315const template = document.createElement('template');316template.innerHTML = ${JSON.stringify(html)};317const replacements = ${JSON.stringify(replacements)};318319for (const img of Array.from(template.content.querySelectorAll('img'))) {320const src = img.getAttribute('src') || '';321const localPath = img.getAttribute('data-local-path') || '';322const replacement = replacements.find((item) => item.placeholder === src || item.localPath === localPath);323if (replacement) {324img.replaceWith(document.createTextNode(replacement.placeholder));325}326}327328const output = template.content.querySelector('#output');329const wrapper = document.createElement('div');330if (output) {331wrapper.innerHTML = output.innerHTML;332} else {333wrapper.appendChild(template.content.cloneNode(true));334}335336editor.focus();337const selection = window.getSelection();338const range = document.createRange();339range.selectNodeContents(editor);340range.deleteContents();341range.collapse(true);342selection.removeAllRanges();343selection.addRange(range);344345const inserted = document.execCommand('insertHTML', false, wrapper.innerHTML);346editor.dispatchEvent(new InputEvent('input', {347bubbles: true,348inputType: 'insertHTML',349data: wrapper.innerText || ''350}));351352return JSON.stringify({353ok: inserted || (editor.innerText || '').trim().length > 0,354textLength: (editor.innerText || '').trim().length355});356})()357`,358returnByValue: true,359}, { sessionId: session.sessionId });360361const parsed = JSON.parse(result.result.value || '{}') as { ok?: boolean; reason?: string; textLength?: number };362if (!parsed.ok) {363throw new Error(`Failed to insert HTML into body editor${parsed.reason ? `: ${parsed.reason}` : ''}`);364}365}366367async function verifyTitleUnchangedBeforeSave(session: ChromeSession, expectedTitle: string): Promise<void> {368if (!expectedTitle) return;369370const actualTitle = await evaluate<string>(session, `document.querySelector('#title')?.value || ''`);371if (actualTitle !== expectedTitle) {372throw new Error(`Title was modified during paste. Expected: "${expectedTitle}", got: "${actualTitle}"`);373}374}375376async function prepareEditorPasteTarget(377session: ChromeSession,378context: string,379options: { clickEditor?: boolean } = {},380): Promise<void> {381await session.cdp.send('Target.activateTarget', { targetId: session.targetId }).catch(() => {});382await sleep(100);383384if (options.clickEditor) {385await clickElement(session, BODY_EDITOR_SELECTOR);386await sleep(200);387}388389const ready = await evaluate<boolean>(session, `390(function() {391const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});392if (!editor) return false;393394const active = document.activeElement;395const selection = window.getSelection();396const selectionInEditor = !!selection && selection.rangeCount > 0 && !!selection.anchorNode && editor.contains(selection.anchorNode);397const focusInEditor = !!active && (active === editor || editor.contains(active));398const activeIsUnsafeInput = !!active && (399active.matches?.('#title, #author, #js_description') ||400((active.tagName === 'INPUT' || active.tagName === 'TEXTAREA') && !editor.contains(active))401);402if (activeIsUnsafeInput) return false;403if (selectionInEditor || focusInEditor) return true;404405if (${JSON.stringify(Boolean(options.clickEditor))}) {406editor.focus();407const nextActive = document.activeElement;408return nextActive === editor || editor.contains(nextActive);409}410411return false;412})()413`);414415if (ready) return;416417const activeElement = await evaluate<string>(session, `418(function() {419const el = document.activeElement;420if (!el) return '(none)';421const id = el.id ? '#' + el.id : '';422const className = typeof el.className === 'string' && el.className ? '.' + el.className.split(/\\s+/).join('.') : '';423return el.tagName.toLowerCase() + id + className;424})()425`);426throw new Error(`Body editor is not focused before ${context}; active element: ${activeElement}`);427}428429async function parseMarkdownWithPlaceholders(430markdownPath: string,431theme?: string,432color?: string,433citeStatus: boolean = true434): Promise<{ title: string; author: string; summary: string; htmlPath: string; contentImages: ImageInfo[] }> {435const __filename = fileURLToPath(import.meta.url);436const __dirname = path.dirname(__filename);437const mdToWechatScript = path.join(__dirname, 'md-to-wechat.ts');438const args = ['-y', 'bun', mdToWechatScript, markdownPath];439if (theme) args.push('--theme', theme);440if (color) args.push('--color', color);441if (!citeStatus) args.push('--no-cite');442443const result = spawnSync('npx', args, { stdio: ['inherit', 'pipe', 'pipe'] });444if (result.status !== 0) {445const stderr = result.stderr?.toString() || '';446throw new Error(`Failed to parse markdown: ${stderr}`);447}448449const output = result.stdout.toString();450return JSON.parse(output);451}452453function parseHtmlMeta(htmlPath: string): { title: string; author: string; summary: string; contentImages: ImageInfo[] } {454const content = fs.readFileSync(htmlPath, 'utf-8');455456let title = '';457const titleMatch = content.match(/<title>([^<]+)<\/title>/i);458if (titleMatch) title = titleMatch[1]!;459460let author = '';461const authorMatch = content.match(/<meta\s+name=["']author["']\s+content=["']([^"']+)["']/i)462|| content.match(/<meta\s+content=["']([^"']+)["']\s+name=["']author["']/i);463if (authorMatch) author = authorMatch[1]!;464465let summary = '';466const descMatch = content.match(/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i)467|| content.match(/<meta\s+content=["']([^"']+)["']\s+name=["']description["']/i);468if (descMatch) summary = descMatch[1]!;469470if (!summary) {471const firstPMatch = content.match(/<p[^>]*>([^<]+)<\/p>/i);472if (firstPMatch) {473const text = firstPMatch[1]!.replace(/<[^>]+>/g, '').trim();474if (text.length > 20) {475summary = text.length > 120 ? text.slice(0, 117) + '...' : text;476}477}478}479480const mdPath = htmlPath.replace(/\.html$/i, '.md');481if (fs.existsSync(mdPath)) {482const mdContent = fs.readFileSync(mdPath, 'utf-8');483const fmMatch = mdContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);484if (fmMatch) {485const lines = fmMatch[1]!.split('\n');486for (const line of lines) {487const colonIdx = line.indexOf(':');488if (colonIdx > 0) {489const key = line.slice(0, colonIdx).trim();490let value = line.slice(colonIdx + 1).trim();491if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {492value = value.slice(1, -1);493}494if (key === 'title' && !title) title = value;495if (key === 'author' && !author) author = value;496if ((key === 'description' || key === 'summary') && !summary) summary = value;497}498}499}500}501502const contentImages: ImageInfo[] = [];503const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi;504const matches = [...content.matchAll(imgRegex)];505for (const match of matches) {506const [fullTag, src] = match;507if (!src || src.startsWith('http')) continue;508const localPathMatch = fullTag.match(/data-local-path=["']([^"']+)["']/);509if (localPathMatch) {510contentImages.push({511placeholder: src,512localPath: localPathMatch[1]!,513originalPath: src,514});515}516}517518return { title, author, summary, contentImages };519}520521async function selectAndReplacePlaceholder(session: ChromeSession, placeholder: string): Promise<boolean> {522const result = await session.cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {523expression: `524(function() {525const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});526if (!editor) return false;527528const placeholder = ${JSON.stringify(placeholder)};529const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false);530let node;531532while ((node = walker.nextNode())) {533const text = node.textContent || '';534let searchStart = 0;535let idx;536// Search for exact match (not prefix of longer placeholder like XIMGPH_1 in XIMGPH_10)537while ((idx = text.indexOf(placeholder, searchStart)) !== -1) {538const afterIdx = idx + placeholder.length;539const charAfter = text[afterIdx];540// Exact match if next char is not a digit541if (charAfter === undefined || !/\\d/.test(charAfter)) {542node.parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });543editor.focus();544545const range = document.createRange();546range.setStart(node, idx);547range.setEnd(node, idx + placeholder.length);548const sel = window.getSelection();549sel.removeAllRanges();550sel.addRange(range);551return true;552}553searchStart = afterIdx;554}555}556return false;557})()558`,559returnByValue: true,560}, { sessionId: session.sessionId });561562return result.result.value;563}564565async function pressDeleteKey(session: ChromeSession): Promise<void> {566await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId: session.sessionId });567await sleep(50);568await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId: session.sessionId });569}570571async function removeExtraEmptyLineAfterImage(session: ChromeSession): Promise<boolean> {572const removed = await evaluate<boolean>(session, `573(function() {574const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});575if (!editor) return false;576577const sel = window.getSelection();578if (!sel || sel.rangeCount === 0) return false;579580let node = sel.anchorNode;581if (!node) return false;582let element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;583if (!element || !editor.contains(element)) return false;584585const isEmptyParagraph = (el) => {586if (!el || el.tagName !== 'P') return false;587const text = (el.textContent || '').trim();588if (text.length > 0) return false;589return el.querySelectorAll('img, figure, video, iframe').length === 0;590};591592const hasImage = (el) => {593if (!el) return false;594return !!el.querySelector('img, figure img, picture img');595};596597const placeCursorAfter = (el) => {598if (!el) return;599const range = document.createRange();600range.setStartAfter(el);601range.collapse(true);602sel.removeAllRanges();603sel.addRange(range);604};605606// Case 1: caret is inside an empty paragraph right after an image block.607const emptyPara = element.closest('p');608if (emptyPara && editor.contains(emptyPara) && isEmptyParagraph(emptyPara)) {609const prev = emptyPara.previousElementSibling;610if (prev && hasImage(prev)) {611emptyPara.remove();612placeCursorAfter(prev);613return true;614}615}616617// Case 2: caret is on the image block itself; remove the next empty paragraph.618const imageBlock = element.closest('figure, p');619if (imageBlock && editor.contains(imageBlock) && hasImage(imageBlock)) {620const next = imageBlock.nextElementSibling;621if (next && isEmptyParagraph(next)) {622next.remove();623placeCursorAfter(imageBlock);624return true;625}626}627628return false;629})()630`);631632if (removed) console.log('[wechat] Removed extra empty line after image.');633return removed;634}635636async function getBodyImageCount(session: ChromeSession): Promise<number> {637return await evaluate<number>(session, `638(function() {639const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});640if (!editor) return 0;641return Array.from(editor.querySelectorAll('img')).filter((img) => !img.classList.contains('ProseMirror-separator')).length;642})()643`);644}645646async function waitForBodyImageCount(session: ChromeSession, minimumCount: number, timeoutMs = 45_000): Promise<boolean> {647const start = Date.now();648while (Date.now() - start < timeoutMs) {649const count = await getBodyImageCount(session);650if (count >= minimumCount) return true;651await sleep(500);652}653return false;654}655656function inferImageContentType(imagePath: string): string {657const ext = path.extname(imagePath).toLowerCase();658switch (ext) {659case '.jpg':660case '.jpeg':661return 'image/jpeg';662case '.png':663return 'image/png';664case '.gif':665return 'image/gif';666case '.webp':667return 'image/webp';668case '.bmp':669return 'image/bmp';670case '.svg':671return 'image/svg+xml';672default:673return 'application/octet-stream';674}675}676677async function uploadImagePathThroughFileInput(678session: ChromeSession,679absolutePath: string,680beforeCount: number,681): Promise<void> {682const documentNode = await session.cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {683depth: -1,684pierce: true,685}, { sessionId: session.sessionId });686const inputNode = await session.cdp.send<{ nodeId: number }>('DOM.querySelector', {687nodeId: documentNode.root.nodeId,688selector: 'input[type="file"][accept*="image"]',689}, { sessionId: session.sessionId });690691if (!inputNode.nodeId) throw new Error('WeChat local image upload input not found');692693await session.cdp.send('DOM.setFileInputFiles', {694nodeId: inputNode.nodeId,695files: [absolutePath],696}, { sessionId: session.sessionId });697698const inserted = await waitForBodyImageCount(session, beforeCount + 1);699if (!inserted) {700const afterCount = await getBodyImageCount(session);701throw new Error(`Image upload did not insert into editor: ${path.basename(absolutePath)} (${beforeCount} -> ${afterCount})`);702}703}704705interface FallbackUploadImage {706uploadPath: string;707wasProcessed: boolean;708processingNotes: string[];709cleanup: () => void;710}711712async function prepareFallbackWechatBodyImageUpload(absolutePath: string): Promise<FallbackUploadImage> {713const buffer = fs.readFileSync(absolutePath);714const prepared = await prepareWechatBodyImageUpload({715buffer,716filename: path.basename(absolutePath),717contentType: inferImageContentType(absolutePath),718fileExt: path.extname(absolutePath).toLowerCase(),719fileSize: buffer.length,720});721722if (!prepared.wasProcessed) {723return {724uploadPath: absolutePath,725wasProcessed: false,726processingNotes: [],727cleanup: () => {},728};729}730731const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-body-image-'));732const uploadPath = path.join(tempDir, prepared.filename);733fs.writeFileSync(uploadPath, prepared.buffer);734735return {736uploadPath,737wasProcessed: true,738processingNotes: prepared.processingNotes,739cleanup: () => fs.rmSync(tempDir, { recursive: true, force: true }),740};741}742743async function uploadImageThroughFileInput(session: ChromeSession, imagePath: string): Promise<void> {744const absolutePath = path.isAbsolute(imagePath) ? imagePath : path.resolve(process.cwd(), imagePath);745if (!fs.existsSync(absolutePath)) throw new Error(`Image file not found: ${absolutePath}`);746747const beforeCount = await getBodyImageCount(session);748try {749await uploadImagePathThroughFileInput(session, absolutePath, beforeCount);750return;751} catch (err) {752const message = err instanceof Error ? err.message : String(err);753if (message.includes('local image upload input not found')) throw err;754755const currentCount = await getBodyImageCount(session);756if (currentCount > beforeCount) return;757758console.warn(`[wechat] Raw image upload failed, retrying with fallback processing: ${message}`);759const fallback = await prepareFallbackWechatBodyImageUpload(absolutePath);760const notes = fallback.processingNotes.length > 0 ? ` (${fallback.processingNotes.join('; ')})` : '';761console.log(`[wechat] Retrying image upload with ${fallback.wasProcessed ? 'processed' : 'original'} file: ${path.basename(fallback.uploadPath)}${notes}`);762763try {764await uploadImagePathThroughFileInput(session, fallback.uploadPath, currentCount);765} finally {766fallback.cleanup();767}768}769}770771interface DraftSaveStatus {772appmsgid: string;773isLoading: boolean;774submitText: string;775url: string;776messages: string[];777}778779async function getDraftSaveStatus(session: ChromeSession): Promise<DraftSaveStatus> {780const raw = await evaluate<string>(session, `781(function() {782const submit = document.querySelector('#js_submit');783const button = submit?.querySelector('button');784const url = location.href;785const appmsgid = new URL(url).searchParams.get('appmsgid') || '';786const messages = Array.from(document.querySelectorAll('.weui-desktop-toast, .weui-desktop-toptips, .js_tips'))787.map((el) => (el.innerText || el.textContent || '').trim())788.filter(Boolean);789return JSON.stringify({790appmsgid,791isLoading: !!submit?.classList.contains('btn_loading') || !!button?.disabled,792submitText: (submit?.innerText || '').trim(),793url,794messages795});796})()797`);798return JSON.parse(raw || '{}') as DraftSaveStatus;799}800801async function waitForDraftSaved(session: ChromeSession, timeoutMs = 60_000): Promise<string> {802const start = Date.now();803let lastStatus: DraftSaveStatus | null = null;804805while (Date.now() - start < timeoutMs) {806lastStatus = await getDraftSaveStatus(session);807if (lastStatus.appmsgid && !lastStatus.isLoading) return lastStatus.appmsgid;808809const relevantFailure = lastStatus.messages.find((message) => /保存.*失败|草稿.*失败|save.*fail/i.test(message));810if (relevantFailure) throw new Error(`Draft save failed: ${relevantFailure}`);811812await sleep(1000);813}814815throw new Error(`Draft save did not complete${lastStatus ? `: ${JSON.stringify(lastStatus)}` : ''}`);816}817818export async function postArticle(options: ArticleOptions): Promise<void> {819const { title, content, htmlFile, markdownFile, theme, color, citeStatus = true, author, summary, images = [], submit = false, profileDir, cdpPort } = options;820let { contentImages = [] } = options;821let effectiveTitle = title || '';822let effectiveAuthor = author || '';823let effectiveSummary = summary || '';824let effectiveHtmlFile = htmlFile;825826if (markdownFile) {827console.log(`[wechat] Parsing markdown: ${markdownFile}`);828const parsed = await parseMarkdownWithPlaceholders(markdownFile, theme, color, citeStatus);829effectiveTitle = effectiveTitle || parsed.title;830effectiveAuthor = effectiveAuthor || parsed.author;831effectiveSummary = effectiveSummary || parsed.summary;832effectiveHtmlFile = parsed.htmlPath;833contentImages = parsed.contentImages;834console.log(`[wechat] Title: ${effectiveTitle || '(empty)'}`);835console.log(`[wechat] Author: ${effectiveAuthor || '(empty)'}`);836console.log(`[wechat] Summary: ${effectiveSummary || '(empty)'}`);837console.log(`[wechat] Found ${contentImages.length} images to insert`);838} else if (htmlFile && fs.existsSync(htmlFile)) {839console.log(`[wechat] Parsing HTML: ${htmlFile}`);840const meta = parseHtmlMeta(htmlFile);841effectiveTitle = effectiveTitle || meta.title;842effectiveAuthor = effectiveAuthor || meta.author;843effectiveSummary = effectiveSummary || meta.summary;844effectiveHtmlFile = htmlFile;845if (meta.contentImages.length > 0) {846contentImages = meta.contentImages;847}848console.log(`[wechat] Title: ${effectiveTitle || '(empty)'}`);849console.log(`[wechat] Author: ${effectiveAuthor || '(empty)'}`);850console.log(`[wechat] Summary: ${effectiveSummary || '(empty)'}`);851console.log(`[wechat] Found ${contentImages.length} images to insert`);852}853854if (effectiveTitle && effectiveTitle.length > 64) throw new Error(`Title too long: ${effectiveTitle.length} chars (max 64)`);855if (!content && !effectiveHtmlFile) throw new Error('Either --content, --html, or --markdown is required');856857let cdp: CdpConnection;858let chrome: ReturnType<typeof import('node:child_process').spawn> | null = null;859860// Try connecting to existing Chrome: explicit port > auto-detect > launch new861const portToTry = cdpPort ?? await findExistingChromeDebugPort(profileDir);862if (portToTry) {863const existing = await tryConnectExisting(portToTry);864if (existing) {865console.log(`[cdp] Connected to existing Chrome on port ${portToTry}`);866cdp = existing;867} else {868console.log(`[cdp] Port ${portToTry} not available, launching new Chrome...`);869const launched = await launchChrome(WECHAT_URL, profileDir);870cdp = launched.cdp;871chrome = launched.chrome;872}873} else {874const launched = await launchChrome(WECHAT_URL, profileDir);875cdp = launched.cdp;876chrome = launched.chrome;877}878879try {880console.log('[wechat] Waiting for page load...');881await sleep(3000);882883let session: ChromeSession;884if (!chrome) {885// Reusing existing Chrome: find an already-logged-in tab (has token in URL)886const allTargets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');887const loggedInTab = allTargets.targetInfos.find(t => t.type === 'page' && t.url.includes('mp.weixin.qq.com') && t.url.includes('token='));888const wechatTab = loggedInTab || allTargets.targetInfos.find(t => t.type === 'page' && t.url.includes('mp.weixin.qq.com'));889890if (wechatTab) {891console.log(`[wechat] Reusing existing tab: ${wechatTab.url.substring(0, 80)}...`);892const { sessionId: reuseSid } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: wechatTab.targetId, flatten: true });893await cdp.send('Page.enable', {}, { sessionId: reuseSid });894await cdp.send('Runtime.enable', {}, { sessionId: reuseSid });895await cdp.send('DOM.enable', {}, { sessionId: reuseSid });896session = { cdp, sessionId: reuseSid, targetId: wechatTab.targetId };897898// Navigate to home if not already there899const currentUrl = await evaluate<string>(session, 'window.location.href');900if (!currentUrl.includes('/cgi-bin/home')) {901console.log('[wechat] Navigating to home...');902await evaluate(session, `window.location.href = '${WECHAT_URL}cgi-bin/home?t=home/index'`);903await sleep(5000);904}905} else {906// No WeChat tab found, create one907console.log('[wechat] No WeChat tab found, opening...');908await cdp.send('Target.createTarget', { url: WECHAT_URL });909await sleep(5000);910session = await getPageSession(cdp, 'mp.weixin.qq.com');911}912} else {913session = await getPageSession(cdp, 'mp.weixin.qq.com');914}915916const url = await evaluate<string>(session, 'window.location.href');917if (!url.includes('/cgi-bin/')) {918const hasTelegram = !!(process.env.TELEGRAM_BOT_TOKEN && process.env.TELEGRAM_CHAT_ID);919console.log(`[wechat] Not logged in. Please scan QR code...${hasTelegram ? ' (sending to Telegram)' : ''}`);920const loggedIn = await waitForLogin(session);921if (!loggedIn) throw new Error('Login timeout');922}923console.log('[wechat] Logged in.');924await sleep(5000);925926// Wait for menu to be ready927const menuReady = await waitForElement(session, '.new-creation__menu', 40_000);928if (!menuReady) throw new Error('Home page menu did not load');929930const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');931const initialIds = new Set(targets.targetInfos.map(t => t.targetId));932933await clickMenuByText(session, '文章');934await sleep(3000);935936const editorTargetId = await waitForNewTab(cdp, initialIds, 'mp.weixin.qq.com');937console.log('[wechat] Editor tab opened.');938939const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: editorTargetId, flatten: true });940session = { cdp, sessionId, targetId: editorTargetId };941942await cdp.send('Page.enable', {}, { sessionId });943await cdp.send('Runtime.enable', {}, { sessionId });944await cdp.send('DOM.enable', {}, { sessionId });945946// Wait for editor elements to fully load947console.log('[wechat] Waiting for editor to load...');948const editorLoaded = await waitForElement(session, '#title', 30_000);949if (!editorLoaded) throw new Error('Editor did not load (#title not found)');950await waitForElement(session, BODY_EDITOR_SELECTOR, 15_000);951await sleep(2000);952953if (effectiveTitle) {954console.log('[wechat] Filling title...');955await evaluate(session, `(function() { const el = document.querySelector('#title'); el.focus(); el.value = ${JSON.stringify(effectiveTitle)}; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); })()`);956}957958if (effectiveAuthor) {959console.log('[wechat] Filling author...');960await evaluate(session, `(function() { const el = document.querySelector('#author'); el.focus(); el.value = ${JSON.stringify(effectiveAuthor)}; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); })()`);961}962963await sleep(500);964965if (effectiveTitle) {966const actualTitle = await evaluate<string>(session, `document.querySelector('#title')?.value || ''`);967if (actualTitle === effectiveTitle) {968console.log('[wechat] Title verified OK.');969} else {970console.warn(`[wechat] Title verification failed. Expected: "${effectiveTitle}", got: "${actualTitle}"`);971}972}973974console.log('[wechat] Clicking on editor...');975await clickElement(session, BODY_EDITOR_SELECTOR);976await sleep(1000);977978console.log('[wechat] Ensuring editor focus...');979await clickElement(session, BODY_EDITOR_SELECTOR);980await sleep(500);981982if (effectiveHtmlFile && fs.existsSync(effectiveHtmlFile)) {983console.log(`[wechat] Inserting HTML content from: ${effectiveHtmlFile}`);984await prepareEditorPasteTarget(session, 'body content paste', { clickEditor: true });985await insertHtmlIntoEditorFromFile(session, effectiveHtmlFile, contentImages);986await sleep(3000);987await verifyTitleUnchangedBeforeSave(session, effectiveTitle);988989const editorHasContent = await evaluate<boolean>(session, `990(function() {991const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});992if (!editor) return false;993const text = editor.innerText?.trim() || '';994return text.length > 0;995})()996`);997if (editorHasContent) {998console.log('[wechat] Body content verified OK.');999} else {1000console.warn('[wechat] Body content verification failed: editor appears empty after paste.');1001}10021003if (contentImages.length > 0) {1004console.log(`[wechat] Inserting ${contentImages.length} images...`);1005for (let i = 0; i < contentImages.length; i++) {1006const img = contentImages[i]!;1007console.log(`[wechat] [${i + 1}/${contentImages.length}] Processing: ${img.placeholder}`);10081009const found = await selectAndReplacePlaceholder(session, img.placeholder);1010if (!found) {1011console.warn(`[wechat] Placeholder not found: ${img.placeholder}`);1012continue;1013}10141015await sleep(500);10161017console.log('[wechat] Deleting placeholder with Backspace...');1018await pressDeleteKey(session);1019await sleep(200);10201021console.log(`[wechat] Uploading image: ${path.basename(img.localPath)}`);1022await prepareEditorPasteTarget(session, 'inline image upload');1023await uploadImageThroughFileInput(session, img.localPath);1024await sleep(1000);1025await verifyTitleUnchangedBeforeSave(session, effectiveTitle);1026await removeExtraEmptyLineAfterImage(session);1027}1028console.log('[wechat] All images inserted.');1029}1030} else if (content) {1031for (const img of images) {1032if (fs.existsSync(img)) {1033console.log(`[wechat] Uploading image: ${img}`);1034await prepareEditorPasteTarget(session, 'leading image upload');1035await uploadImageThroughFileInput(session, img);1036await sleep(1000);1037await removeExtraEmptyLineAfterImage(session);1038}1039}10401041console.log('[wechat] Typing content...');1042await prepareEditorPasteTarget(session, 'content typing');1043await typeText(session, content);1044await sleep(1000);10451046const editorHasContent = await evaluate<boolean>(session, `1047(function() {1048const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});1049if (!editor) return false;1050const text = editor.innerText?.trim() || '';1051return text.length > 0;1052})()1053`);1054if (editorHasContent) {1055console.log('[wechat] Body content verified OK.');1056} else {1057console.warn('[wechat] Body content verification failed: editor appears empty after typing.');1058}1059}10601061if (effectiveSummary) {1062console.log(`[wechat] Filling summary (after content paste): ${effectiveSummary}`);1063await evaluate(session, `1064(function() {1065const el = document.querySelector('#js_description');1066if (!el) return;1067el.focus();1068el.select();1069el.value = ${JSON.stringify(effectiveSummary)};1070el.dispatchEvent(new Event('input', { bubbles: true }));1071el.dispatchEvent(new Event('change', { bubbles: true }));1072el.dispatchEvent(new Event('blur', { bubbles: true }));1073})()1074`);1075await sleep(500);10761077const actualSummary = await evaluate<string>(session, `document.querySelector('#js_description')?.value || ''`);1078if (actualSummary === effectiveSummary) {1079console.log('[wechat] Summary verified OK.');1080} else {1081console.warn(`[wechat] Summary verification failed. Expected: "${effectiveSummary}", got: "${actualSummary}"`);1082}1083}10841085await verifyTitleUnchangedBeforeSave(session, effectiveTitle);10861087console.log('[wechat] Saving as draft...');1088await evaluate(session, `document.querySelector('#js_submit button').click()`);1089const appmsgid = await waitForDraftSaved(session);1090console.log(`[wechat] Draft saved successfully! appmsgid: ${appmsgid}`);10911092console.log('[wechat] Done. Browser window left open.');1093} finally {1094cdp.close();1095}1096}10971098function printUsage(): never {1099console.log(`Post article to WeChat Official Account11001101Usage:1102npx -y bun wechat-article.ts [options]11031104Options:1105--title <text> Article title (auto-extracted from markdown)1106--content <text> Article content (use with --image)1107--html <path> HTML file to paste (alternative to --content)1108--markdown <path> Markdown file to convert and post (recommended)1109--theme <name> Theme for markdown (default, grace, simple, modern)1110--color <name|hex> Primary color (blue, green, vermilion, etc. or hex)1111--no-cite Disable bottom citations for ordinary external links in markdown mode1112--author <name> Author name1113--summary <text> Article summary1114--image <path> Content image, can repeat (only with --content)1115--submit Save as draft1116--profile <dir> Chrome profile directory1117--account <alias> Select account by alias (for multi-account setups)1118--cdp-port <port> Connect to existing Chrome debug port instead of launching new instance11191120Examples:1121npx -y bun wechat-article.ts --markdown article.md1122npx -y bun wechat-article.ts --markdown article.md --theme grace --submit1123npx -y bun wechat-article.ts --markdown article.md --no-cite1124npx -y bun wechat-article.ts --title "标题" --content "内容" --image img.png1125npx -y bun wechat-article.ts --title "标题" --html article.html --submit11261127Markdown mode:1128Images in markdown are converted to placeholders. After pasting HTML,1129each placeholder is selected, scrolled into view, deleted, and replaced1130with the actual image via paste. Ordinary external links are converted to1131bottom citations by default.1132`);1133process.exit(0);1134}11351136async function main(): Promise<void> {1137const args = process.argv.slice(2);1138if (args.includes('--help') || args.includes('-h')) printUsage();11391140const images: string[] = [];1141let title: string | undefined;1142let content: string | undefined;1143let htmlFile: string | undefined;1144let markdownFile: string | undefined;1145let theme: string | undefined;1146let color: string | undefined;1147let citeStatus = true;1148let author: string | undefined;1149let summary: string | undefined;1150let submit = false;1151let profileDir: string | undefined;1152let cdpPort: number | undefined;1153let accountAlias: string | undefined;11541155for (let i = 0; i < args.length; i++) {1156const arg = args[i]!;1157if (arg === '--title' && args[i + 1]) title = args[++i];1158else if (arg === '--content' && args[i + 1]) content = args[++i];1159else if (arg === '--html' && args[i + 1]) htmlFile = args[++i];1160else if (arg === '--markdown' && args[i + 1]) markdownFile = args[++i];1161else if (arg === '--theme' && args[i + 1]) theme = args[++i];1162else if (arg === '--color' && args[i + 1]) color = args[++i];1163else if (arg === '--cite') citeStatus = true;1164else if (arg === '--no-cite') citeStatus = false;1165else if (arg === '--author' && args[i + 1]) author = args[++i];1166else if (arg === '--summary' && args[i + 1]) summary = args[++i];1167else if (arg === '--image' && args[i + 1]) images.push(args[++i]!);1168else if (arg === '--submit') submit = true;1169else if (arg === '--profile' && args[i + 1]) profileDir = args[++i];1170else if (arg === '--account' && args[i + 1]) accountAlias = args[++i];1171else if (arg === '--cdp-port' && args[i + 1]) cdpPort = parseInt(args[++i]!, 10);1172}11731174const extConfig = loadWechatExtendConfig();1175const resolved = resolveAccount(extConfig, accountAlias);1176if (resolved.name) console.log(`[wechat] Account: ${resolved.name} (${resolved.alias})`);11771178if (!author && resolved.default_author) author = resolved.default_author;11791180if (!profileDir && resolved.alias) {1181profileDir = resolved.chrome_profile_path || getAccountProfileDir(resolved.alias);1182}11831184if (!markdownFile && !htmlFile && !title) { console.error('Error: --title is required (or use --markdown/--html)'); process.exit(1); }1185if (!markdownFile && !htmlFile && !content) { console.error('Error: --content, --html, or --markdown is required'); process.exit(1); }11861187await postArticle({ title: title || '', content, htmlFile, markdownFile, theme, color, citeStatus, author, summary, images, submit, profileDir, cdpPort });1188}11891190await main().then(() => {1191process.exit(0);1192}).catch((err) => {1193console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);1194process.exit(1);1195});1196