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 path from 'node:path';3import { spawnSync } from 'node:child_process';4import process from 'node:process';5import { fileURLToPath } from 'node:url';6import { launchChrome, tryConnectExisting, findExistingChromeDebugPort, getPageSession, waitForNewTab, clickElement, typeText, evaluate, sleep, getAccountProfileDir, type ChromeSession, type CdpConnection } from './cdp.ts';7import { loadWechatExtendConfig, resolveAccount } from './wechat-extend-config.ts';89const WECHAT_URL = 'https://mp.weixin.qq.com/';1011interface ImageInfo {12placeholder: string;13localPath: string;14originalPath: string;15}1617interface ArticleOptions {18title: string;19content?: string;20htmlFile?: string;21markdownFile?: string;22theme?: string;23color?: string;24citeStatus?: boolean;25author?: string;26summary?: string;27images?: string[];28contentImages?: ImageInfo[];29submit?: boolean;30profileDir?: string;31cdpPort?: number;32}3334async function sendQrToTelegram(session: ChromeSession): Promise<void> {35const botToken = process.env.TELEGRAM_BOT_TOKEN;36const chatId = process.env.TELEGRAM_CHAT_ID;37if (!botToken || !chatId) return;3839// Wait for QR to render before extracting40await sleep(2000);4142try {43// Try to extract QR image from DOM first (avoids full-page screenshot noise)44const domResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {45expression: `46(function() {47const selectors = [48'.login__type__container__scan img',49'.login_img img',50'#login_container img',51'.qrcode img',52'img[src*="qrcode"]',53'img[src*="login"]',54];55for (const sel of selectors) {56const el = document.querySelector(sel);57if (el?.src && !el.src.startsWith('data:,')) return el.src.startsWith('data:') ? el.src : 'url:' + el.src;58}59const canvas = document.querySelector('canvas');60if (canvas) try { return canvas.toDataURL('image/png'); } catch {}61return '';62})()63`,64returnByValue: true,65}, { sessionId: session.sessionId });6667const raw = (domResult.result.value as string) ?? '';68let imgBuffer: Buffer;6970if (raw.startsWith('data:image')) {71imgBuffer = Buffer.from(raw.split(',')[1] ?? '', 'base64');72} else if (raw.startsWith('url:')) {73// Fetch inside Chrome to carry WeChat session cookies74const imgUrl = raw.slice(4);75const inBrowserFetch = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {76expression: `77(async () => {78const resp = await fetch(${JSON.stringify(imgUrl)}, { credentials: 'include' });79const buf = await resp.arrayBuffer();80const bytes = new Uint8Array(buf);81let b = '';82for (let i = 0; i < bytes.length; i++) b += String.fromCharCode(bytes[i]);83return btoa(b);84})()85`,86returnByValue: true,87awaitPromise: true,88}, { sessionId: session.sessionId });89imgBuffer = Buffer.from((inBrowserFetch.result.value as string) ?? '', 'base64');90} else {91// Fallback: viewport screenshot (smaller than full-page; QR is usually in viewport)92const screenshotResp = await session.cdp.send<{ data: string }>(93'Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }, { sessionId: session.sessionId }94);95imgBuffer = Buffer.from(screenshotResp.data ?? '', 'base64');96}9798const boundary = `tgboundary${Date.now()}`;99const parts: Buffer[] = [100Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="chat_id"\r\n\r\n${chatId}\r\n`),101Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="caption"\r\n\r\nWeChat QR code — please scan to log in\r\n`),102Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="photo"; filename="qr.png"\r\nContent-Type: image/png\r\n\r\n`),103imgBuffer,104Buffer.from(`\r\n--${boundary}--\r\n`),105];106const tgResp = await fetch(`https://api.telegram.org/bot${botToken}/sendPhoto`, {107method: 'POST',108headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` },109body: Buffer.concat(parts),110signal: AbortSignal.timeout(10_000),111});112const tgJson = await tgResp.json() as { ok: boolean; description?: string };113if (tgJson.ok) {114console.log('[wechat] QR code sent to Telegram.');115} else {116console.error('[wechat] Telegram send failed:', tgJson.description);117}118} catch (err) {119console.error('[wechat] Failed to send QR to Telegram:', err);120}121}122123async function waitForLogin(session: ChromeSession, timeoutMs = 120_000): Promise<boolean> {124// Notify via Telegram if configured (no-op when env vars absent)125await sendQrToTelegram(session);126const start = Date.now();127while (Date.now() - start < timeoutMs) {128const url = await evaluate<string>(session, 'window.location.href');129if (url.includes('/cgi-bin/home')) return true;130await sleep(2000);131}132return false;133}134135async function waitForElement(session: ChromeSession, selector: string, timeoutMs = 10_000): Promise<boolean> {136const start = Date.now();137while (Date.now() - start < timeoutMs) {138const found = await evaluate<boolean>(session, `!!document.querySelector('${selector}')`);139if (found) return true;140await sleep(500);141}142return false;143}144145async function clickMenuByText(session: ChromeSession, text: string, maxRetries = 5): Promise<void> {146console.log(`[wechat] Clicking "${text}" menu...`);147for (let attempt = 1; attempt <= maxRetries; attempt++) {148const posResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {149expression: `150(function() {151const items = document.querySelectorAll('.new-creation__menu .new-creation__menu-item');152for (const item of items) {153const title = item.querySelector('.new-creation__menu-title');154if (title && title.textContent?.trim() === '${text}') {155item.scrollIntoView({ block: 'center' });156const rect = item.getBoundingClientRect();157return JSON.stringify({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 });158}159}160return 'null';161})()162`,163returnByValue: true,164}, { sessionId: session.sessionId });165166if (posResult.result.value !== 'null') {167const pos = JSON.parse(posResult.result.value);168await session.cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });169await sleep(100);170await session.cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });171return;172}173174if (attempt < maxRetries) {175const delay = Math.min(1000 * attempt, 3000);176console.log(`[wechat] Menu "${text}" not found, retrying in ${delay}ms (${attempt}/${maxRetries})...`);177await sleep(delay);178}179}180throw new Error(`Menu "${text}" not found after ${maxRetries} attempts`);181}182183async function copyImageToClipboard(imagePath: string): Promise<void> {184const __filename = fileURLToPath(import.meta.url);185const __dirname = path.dirname(__filename);186const copyScript = path.join(__dirname, './copy-to-clipboard.ts');187const result = spawnSync('npx', ['-y', 'bun', copyScript, 'image', imagePath], { stdio: 'inherit' });188if (result.status !== 0) throw new Error(`Failed to copy image: ${imagePath}`);189}190191async function pasteInEditor(session: ChromeSession): Promise<void> {192const modifiers = process.platform === 'darwin' ? 4 : 2;193await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId });194await sleep(50);195await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId });196}197198async function sendCopy(cdp?: CdpConnection, sessionId?: string): Promise<void> {199if (process.platform === 'darwin') {200spawnSync('osascript', ['-e', 'tell application "System Events" to keystroke "c" using command down']);201} else if (process.platform === 'linux') {202spawnSync('xdotool', ['key', 'ctrl+c']);203} else if (cdp && sessionId) {204await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'c', code: 'KeyC', modifiers: 2, windowsVirtualKeyCode: 67 }, { sessionId });205await sleep(50);206await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'c', code: 'KeyC', modifiers: 2, windowsVirtualKeyCode: 67 }, { sessionId });207}208}209210async function sendPaste(cdp?: CdpConnection, sessionId?: string): Promise<void> {211if (process.platform === 'darwin') {212spawnSync('osascript', ['-e', 'tell application "System Events" to keystroke "v" using command down']);213} else if (process.platform === 'linux') {214spawnSync('xdotool', ['key', 'ctrl+v']);215} else if (cdp && sessionId) {216await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'v', code: 'KeyV', modifiers: 2, windowsVirtualKeyCode: 86 }, { sessionId });217await sleep(50);218await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'v', code: 'KeyV', modifiers: 2, windowsVirtualKeyCode: 86 }, { sessionId });219}220}221222async function copyHtmlFromBrowser(cdp: CdpConnection, htmlFilePath: string, contentImages: ImageInfo[] = []): Promise<void> {223const absolutePath = path.isAbsolute(htmlFilePath) ? htmlFilePath : path.resolve(process.cwd(), htmlFilePath);224const fileUrl = `file://${absolutePath}`;225226console.log(`[wechat] Opening HTML file in new tab: ${fileUrl}`);227228const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: fileUrl });229const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId, flatten: true });230231await cdp.send('Page.enable', {}, { sessionId });232await cdp.send('Runtime.enable', {}, { sessionId });233await sleep(2000);234235if (contentImages.length > 0) {236console.log('[wechat] Replacing img tags with placeholders for browser paste...');237const replacements = contentImages.map(img => ({ placeholder: img.placeholder, localPath: img.localPath }));238await cdp.send<{ result: { value: unknown } }>('Runtime.evaluate', {239expression: `240(function() {241const replacements = ${JSON.stringify(replacements)};242for (const r of replacements) {243const imgs = document.querySelectorAll('img[src="' + r.placeholder + '"], img[data-local-path="' + r.localPath + '"]');244for (const img of imgs) {245const text = document.createTextNode(r.placeholder);246img.parentNode.replaceChild(text, img);247}248}249return true;250})()251`,252returnByValue: true,253}, { sessionId });254await sleep(500);255}256257console.log('[wechat] Selecting #output content...');258await cdp.send<{ result: { value: unknown } }>('Runtime.evaluate', {259expression: `260(function() {261const output = document.querySelector('#output') || document.body;262const range = document.createRange();263range.selectNodeContents(output);264const selection = window.getSelection();265selection.removeAllRanges();266selection.addRange(range);267return true;268})()269`,270returnByValue: true,271}, { sessionId });272await sleep(300);273274console.log('[wechat] Activating HTML tab for copy...');275await cdp.send('Target.activateTarget', { targetId });276await sleep(300);277278console.log('[wechat] Copying content...');279await sendCopy(cdp, sessionId);280await sleep(1000);281282console.log('[wechat] Closing HTML tab...');283await cdp.send('Target.closeTarget', { targetId });284}285286async function pasteFromClipboardInEditor(session: ChromeSession): Promise<void> {287console.log('[wechat] Activating editor tab for paste...');288if (session.targetId) {289await session.cdp.send('Target.activateTarget', { targetId: session.targetId });290await sleep(300);291}292console.log('[wechat] Pasting content...');293await sendPaste(session.cdp, session.sessionId);294await sleep(1000);295}296297async function prepareEditorPasteTarget(298session: ChromeSession,299context: string,300options: { clickEditor?: boolean } = {},301): Promise<void> {302await session.cdp.send('Target.activateTarget', { targetId: session.targetId }).catch(() => {});303await sleep(100);304305if (options.clickEditor) {306await clickElement(session, '.ProseMirror');307await sleep(200);308}309310const ready = await evaluate<boolean>(session, `311(function() {312const editor = document.querySelector('.ProseMirror');313if (!editor) return false;314315const active = document.activeElement;316const selection = window.getSelection();317const selectionInEditor = !!selection && selection.rangeCount > 0 && !!selection.anchorNode && editor.contains(selection.anchorNode);318const focusInEditor = !!active && (active === editor || editor.contains(active));319if (selectionInEditor || focusInEditor) return true;320321if (${JSON.stringify(Boolean(options.clickEditor))}) {322editor.focus();323const nextActive = document.activeElement;324return nextActive === editor || editor.contains(nextActive);325}326327return false;328})()329`);330331if (ready) return;332333const activeElement = await evaluate<string>(session, `334(function() {335const el = document.activeElement;336if (!el) return '(none)';337const id = el.id ? '#' + el.id : '';338const className = typeof el.className === 'string' && el.className ? '.' + el.className.split(/\\s+/).join('.') : '';339return el.tagName.toLowerCase() + id + className;340})()341`);342throw new Error(`Body editor is not focused before ${context}; active element: ${activeElement}`);343}344345async function parseMarkdownWithPlaceholders(346markdownPath: string,347theme?: string,348color?: string,349citeStatus: boolean = true350): Promise<{ title: string; author: string; summary: string; htmlPath: string; contentImages: ImageInfo[] }> {351const __filename = fileURLToPath(import.meta.url);352const __dirname = path.dirname(__filename);353const mdToWechatScript = path.join(__dirname, 'md-to-wechat.ts');354const args = ['-y', 'bun', mdToWechatScript, markdownPath];355if (theme) args.push('--theme', theme);356if (color) args.push('--color', color);357if (!citeStatus) args.push('--no-cite');358359const result = spawnSync('npx', args, { stdio: ['inherit', 'pipe', 'pipe'] });360if (result.status !== 0) {361const stderr = result.stderr?.toString() || '';362throw new Error(`Failed to parse markdown: ${stderr}`);363}364365const output = result.stdout.toString();366return JSON.parse(output);367}368369function parseHtmlMeta(htmlPath: string): { title: string; author: string; summary: string; contentImages: ImageInfo[] } {370const content = fs.readFileSync(htmlPath, 'utf-8');371372let title = '';373const titleMatch = content.match(/<title>([^<]+)<\/title>/i);374if (titleMatch) title = titleMatch[1]!;375376let author = '';377const authorMatch = content.match(/<meta\s+name=["']author["']\s+content=["']([^"']+)["']/i)378|| content.match(/<meta\s+content=["']([^"']+)["']\s+name=["']author["']/i);379if (authorMatch) author = authorMatch[1]!;380381let summary = '';382const descMatch = content.match(/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i)383|| content.match(/<meta\s+content=["']([^"']+)["']\s+name=["']description["']/i);384if (descMatch) summary = descMatch[1]!;385386if (!summary) {387const firstPMatch = content.match(/<p[^>]*>([^<]+)<\/p>/i);388if (firstPMatch) {389const text = firstPMatch[1]!.replace(/<[^>]+>/g, '').trim();390if (text.length > 20) {391summary = text.length > 120 ? text.slice(0, 117) + '...' : text;392}393}394}395396const mdPath = htmlPath.replace(/\.html$/i, '.md');397if (fs.existsSync(mdPath)) {398const mdContent = fs.readFileSync(mdPath, 'utf-8');399const fmMatch = mdContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);400if (fmMatch) {401const lines = fmMatch[1]!.split('\n');402for (const line of lines) {403const colonIdx = line.indexOf(':');404if (colonIdx > 0) {405const key = line.slice(0, colonIdx).trim();406let value = line.slice(colonIdx + 1).trim();407if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {408value = value.slice(1, -1);409}410if (key === 'title' && !title) title = value;411if (key === 'author' && !author) author = value;412if ((key === 'description' || key === 'summary') && !summary) summary = value;413}414}415}416}417418const contentImages: ImageInfo[] = [];419const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi;420const matches = [...content.matchAll(imgRegex)];421for (const match of matches) {422const [fullTag, src] = match;423if (!src || src.startsWith('http')) continue;424const localPathMatch = fullTag.match(/data-local-path=["']([^"']+)["']/);425if (localPathMatch) {426contentImages.push({427placeholder: src,428localPath: localPathMatch[1]!,429originalPath: src,430});431}432}433434return { title, author, summary, contentImages };435}436437async function selectAndReplacePlaceholder(session: ChromeSession, placeholder: string): Promise<boolean> {438const result = await session.cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {439expression: `440(function() {441const editor = document.querySelector('.ProseMirror');442if (!editor) return false;443444const placeholder = ${JSON.stringify(placeholder)};445const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false);446let node;447448while ((node = walker.nextNode())) {449const text = node.textContent || '';450let searchStart = 0;451let idx;452// Search for exact match (not prefix of longer placeholder like XIMGPH_1 in XIMGPH_10)453while ((idx = text.indexOf(placeholder, searchStart)) !== -1) {454const afterIdx = idx + placeholder.length;455const charAfter = text[afterIdx];456// Exact match if next char is not a digit457if (charAfter === undefined || !/\\d/.test(charAfter)) {458node.parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });459460const range = document.createRange();461range.setStart(node, idx);462range.setEnd(node, idx + placeholder.length);463const sel = window.getSelection();464sel.removeAllRanges();465sel.addRange(range);466return true;467}468searchStart = afterIdx;469}470}471return false;472})()473`,474returnByValue: true,475}, { sessionId: session.sessionId });476477return result.result.value;478}479480async function pressDeleteKey(session: ChromeSession): Promise<void> {481await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId: session.sessionId });482await sleep(50);483await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId: session.sessionId });484}485486async function removeExtraEmptyLineAfterImage(session: ChromeSession): Promise<boolean> {487const removed = await evaluate<boolean>(session, `488(function() {489const editor = document.querySelector('.ProseMirror');490if (!editor) return false;491492const sel = window.getSelection();493if (!sel || sel.rangeCount === 0) return false;494495let node = sel.anchorNode;496if (!node) return false;497let element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;498if (!element || !editor.contains(element)) return false;499500const isEmptyParagraph = (el) => {501if (!el || el.tagName !== 'P') return false;502const text = (el.textContent || '').trim();503if (text.length > 0) return false;504return el.querySelectorAll('img, figure, video, iframe').length === 0;505};506507const hasImage = (el) => {508if (!el) return false;509return !!el.querySelector('img, figure img, picture img');510};511512const placeCursorAfter = (el) => {513if (!el) return;514const range = document.createRange();515range.setStartAfter(el);516range.collapse(true);517sel.removeAllRanges();518sel.addRange(range);519};520521// Case 1: caret is inside an empty paragraph right after an image block.522const emptyPara = element.closest('p');523if (emptyPara && editor.contains(emptyPara) && isEmptyParagraph(emptyPara)) {524const prev = emptyPara.previousElementSibling;525if (prev && hasImage(prev)) {526emptyPara.remove();527placeCursorAfter(prev);528return true;529}530}531532// Case 2: caret is on the image block itself; remove the next empty paragraph.533const imageBlock = element.closest('figure, p');534if (imageBlock && editor.contains(imageBlock) && hasImage(imageBlock)) {535const next = imageBlock.nextElementSibling;536if (next && isEmptyParagraph(next)) {537next.remove();538placeCursorAfter(imageBlock);539return true;540}541}542543return false;544})()545`);546547if (removed) console.log('[wechat] Removed extra empty line after image.');548return removed;549}550551export async function postArticle(options: ArticleOptions): Promise<void> {552const { title, content, htmlFile, markdownFile, theme, color, citeStatus = true, author, summary, images = [], submit = false, profileDir, cdpPort } = options;553let { contentImages = [] } = options;554let effectiveTitle = title || '';555let effectiveAuthor = author || '';556let effectiveSummary = summary || '';557let effectiveHtmlFile = htmlFile;558559if (markdownFile) {560console.log(`[wechat] Parsing markdown: ${markdownFile}`);561const parsed = await parseMarkdownWithPlaceholders(markdownFile, theme, color, citeStatus);562effectiveTitle = effectiveTitle || parsed.title;563effectiveAuthor = effectiveAuthor || parsed.author;564effectiveSummary = effectiveSummary || parsed.summary;565effectiveHtmlFile = parsed.htmlPath;566contentImages = parsed.contentImages;567console.log(`[wechat] Title: ${effectiveTitle || '(empty)'}`);568console.log(`[wechat] Author: ${effectiveAuthor || '(empty)'}`);569console.log(`[wechat] Summary: ${effectiveSummary || '(empty)'}`);570console.log(`[wechat] Found ${contentImages.length} images to insert`);571} else if (htmlFile && fs.existsSync(htmlFile)) {572console.log(`[wechat] Parsing HTML: ${htmlFile}`);573const meta = parseHtmlMeta(htmlFile);574effectiveTitle = effectiveTitle || meta.title;575effectiveAuthor = effectiveAuthor || meta.author;576effectiveSummary = effectiveSummary || meta.summary;577effectiveHtmlFile = htmlFile;578if (meta.contentImages.length > 0) {579contentImages = meta.contentImages;580}581console.log(`[wechat] Title: ${effectiveTitle || '(empty)'}`);582console.log(`[wechat] Author: ${effectiveAuthor || '(empty)'}`);583console.log(`[wechat] Summary: ${effectiveSummary || '(empty)'}`);584console.log(`[wechat] Found ${contentImages.length} images to insert`);585}586587if (effectiveTitle && effectiveTitle.length > 64) throw new Error(`Title too long: ${effectiveTitle.length} chars (max 64)`);588if (!content && !effectiveHtmlFile) throw new Error('Either --content, --html, or --markdown is required');589590let cdp: CdpConnection;591let chrome: ReturnType<typeof import('node:child_process').spawn> | null = null;592593// Try connecting to existing Chrome: explicit port > auto-detect > launch new594const portToTry = cdpPort ?? await findExistingChromeDebugPort();595if (portToTry) {596const existing = await tryConnectExisting(portToTry);597if (existing) {598console.log(`[cdp] Connected to existing Chrome on port ${portToTry}`);599cdp = existing;600} else {601console.log(`[cdp] Port ${portToTry} not available, launching new Chrome...`);602const launched = await launchChrome(WECHAT_URL, profileDir);603cdp = launched.cdp;604chrome = launched.chrome;605}606} else {607const launched = await launchChrome(WECHAT_URL, profileDir);608cdp = launched.cdp;609chrome = launched.chrome;610}611612try {613console.log('[wechat] Waiting for page load...');614await sleep(3000);615616let session: ChromeSession;617if (!chrome) {618// Reusing existing Chrome: find an already-logged-in tab (has token in URL)619const allTargets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');620const loggedInTab = allTargets.targetInfos.find(t => t.type === 'page' && t.url.includes('mp.weixin.qq.com') && t.url.includes('token='));621const wechatTab = loggedInTab || allTargets.targetInfos.find(t => t.type === 'page' && t.url.includes('mp.weixin.qq.com'));622623if (wechatTab) {624console.log(`[wechat] Reusing existing tab: ${wechatTab.url.substring(0, 80)}...`);625const { sessionId: reuseSid } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: wechatTab.targetId, flatten: true });626await cdp.send('Page.enable', {}, { sessionId: reuseSid });627await cdp.send('Runtime.enable', {}, { sessionId: reuseSid });628await cdp.send('DOM.enable', {}, { sessionId: reuseSid });629session = { cdp, sessionId: reuseSid, targetId: wechatTab.targetId };630631// Navigate to home if not already there632const currentUrl = await evaluate<string>(session, 'window.location.href');633if (!currentUrl.includes('/cgi-bin/home')) {634console.log('[wechat] Navigating to home...');635await evaluate(session, `window.location.href = '${WECHAT_URL}cgi-bin/home?t=home/index'`);636await sleep(5000);637}638} else {639// No WeChat tab found, create one640console.log('[wechat] No WeChat tab found, opening...');641await cdp.send('Target.createTarget', { url: WECHAT_URL });642await sleep(5000);643session = await getPageSession(cdp, 'mp.weixin.qq.com');644}645} else {646session = await getPageSession(cdp, 'mp.weixin.qq.com');647}648649const url = await evaluate<string>(session, 'window.location.href');650if (!url.includes('/cgi-bin/')) {651const hasTelegram = !!(process.env.TELEGRAM_BOT_TOKEN && process.env.TELEGRAM_CHAT_ID);652console.log(`[wechat] Not logged in. Please scan QR code...${hasTelegram ? ' (sending to Telegram)' : ''}`);653const loggedIn = await waitForLogin(session);654if (!loggedIn) throw new Error('Login timeout');655}656console.log('[wechat] Logged in.');657await sleep(5000);658659// Wait for menu to be ready660const menuReady = await waitForElement(session, '.new-creation__menu', 40_000);661if (!menuReady) throw new Error('Home page menu did not load');662663const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');664const initialIds = new Set(targets.targetInfos.map(t => t.targetId));665666await clickMenuByText(session, '文章');667await sleep(3000);668669const editorTargetId = await waitForNewTab(cdp, initialIds, 'mp.weixin.qq.com');670console.log('[wechat] Editor tab opened.');671672const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: editorTargetId, flatten: true });673session = { cdp, sessionId, targetId: editorTargetId };674675await cdp.send('Page.enable', {}, { sessionId });676await cdp.send('Runtime.enable', {}, { sessionId });677await cdp.send('DOM.enable', {}, { sessionId });678679// Wait for editor elements to fully load680console.log('[wechat] Waiting for editor to load...');681const editorLoaded = await waitForElement(session, '#title', 30_000);682if (!editorLoaded) throw new Error('Editor did not load (#title not found)');683await waitForElement(session, '.ProseMirror', 15_000);684await sleep(2000);685686if (effectiveTitle) {687console.log('[wechat] Filling title...');688await 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 })); })()`);689}690691if (effectiveAuthor) {692console.log('[wechat] Filling author...');693await 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 })); })()`);694}695696await sleep(500);697698if (effectiveTitle) {699const actualTitle = await evaluate<string>(session, `document.querySelector('#title')?.value || ''`);700if (actualTitle === effectiveTitle) {701console.log('[wechat] Title verified OK.');702} else {703console.warn(`[wechat] Title verification failed. Expected: "${effectiveTitle}", got: "${actualTitle}"`);704}705}706707console.log('[wechat] Clicking on editor...');708await clickElement(session, '.ProseMirror');709await sleep(1000);710711console.log('[wechat] Ensuring editor focus...');712await clickElement(session, '.ProseMirror');713await sleep(500);714715if (effectiveHtmlFile && fs.existsSync(effectiveHtmlFile)) {716console.log(`[wechat] Copying HTML content from: ${effectiveHtmlFile}`);717await copyHtmlFromBrowser(cdp, effectiveHtmlFile, contentImages);718await sleep(500);719await prepareEditorPasteTarget(session, 'body content paste', { clickEditor: true });720console.log('[wechat] Pasting into editor...');721await pasteFromClipboardInEditor(session);722await sleep(3000);723724const editorHasContent = await evaluate<boolean>(session, `725(function() {726const editor = document.querySelector('.ProseMirror');727if (!editor) return false;728const text = editor.innerText?.trim() || '';729return text.length > 0;730})()731`);732if (editorHasContent) {733console.log('[wechat] Body content verified OK.');734} else {735console.warn('[wechat] Body content verification failed: editor appears empty after paste.');736}737738if (contentImages.length > 0) {739console.log(`[wechat] Inserting ${contentImages.length} images...`);740for (let i = 0; i < contentImages.length; i++) {741const img = contentImages[i]!;742console.log(`[wechat] [${i + 1}/${contentImages.length}] Processing: ${img.placeholder}`);743744const found = await selectAndReplacePlaceholder(session, img.placeholder);745if (!found) {746console.warn(`[wechat] Placeholder not found: ${img.placeholder}`);747continue;748}749750await sleep(500);751752console.log(`[wechat] Copying image: ${path.basename(img.localPath)}`);753await copyImageToClipboard(img.localPath);754await sleep(300);755756console.log('[wechat] Deleting placeholder with Backspace...');757await pressDeleteKey(session);758await sleep(200);759760console.log('[wechat] Pasting image...');761await prepareEditorPasteTarget(session, 'inline image paste');762await pasteFromClipboardInEditor(session);763await sleep(3000);764await removeExtraEmptyLineAfterImage(session);765}766console.log('[wechat] All images inserted.');767}768} else if (content) {769for (const img of images) {770if (fs.existsSync(img)) {771console.log(`[wechat] Pasting image: ${img}`);772await copyImageToClipboard(img);773await sleep(500);774await prepareEditorPasteTarget(session, 'leading image paste');775await pasteInEditor(session);776await sleep(2000);777await removeExtraEmptyLineAfterImage(session);778}779}780781console.log('[wechat] Typing content...');782await prepareEditorPasteTarget(session, 'content typing');783await typeText(session, content);784await sleep(1000);785786const editorHasContent = await evaluate<boolean>(session, `787(function() {788const editor = document.querySelector('.ProseMirror');789if (!editor) return false;790const text = editor.innerText?.trim() || '';791return text.length > 0;792})()793`);794if (editorHasContent) {795console.log('[wechat] Body content verified OK.');796} else {797console.warn('[wechat] Body content verification failed: editor appears empty after typing.');798}799}800801if (effectiveSummary) {802console.log(`[wechat] Filling summary (after content paste): ${effectiveSummary}`);803await evaluate(session, `804(function() {805const el = document.querySelector('#js_description');806if (!el) return;807el.focus();808el.select();809el.value = ${JSON.stringify(effectiveSummary)};810el.dispatchEvent(new Event('input', { bubbles: true }));811el.dispatchEvent(new Event('change', { bubbles: true }));812el.dispatchEvent(new Event('blur', { bubbles: true }));813})()814`);815await sleep(500);816817const actualSummary = await evaluate<string>(session, `document.querySelector('#js_description')?.value || ''`);818if (actualSummary === effectiveSummary) {819console.log('[wechat] Summary verified OK.');820} else {821console.warn(`[wechat] Summary verification failed. Expected: "${effectiveSummary}", got: "${actualSummary}"`);822}823}824825console.log('[wechat] Saving as draft...');826await evaluate(session, `document.querySelector('#js_submit button').click()`);827await sleep(3000);828829const saved = await evaluate<boolean>(session, `!!document.querySelector('.weui-desktop-toast')`);830if (saved) {831console.log('[wechat] Draft saved successfully!');832} else {833console.log('[wechat] Waiting for save confirmation...');834await sleep(5000);835}836837console.log('[wechat] Done. Browser window left open.');838} finally {839cdp.close();840}841}842843function printUsage(): never {844console.log(`Post article to WeChat Official Account845846Usage:847npx -y bun wechat-article.ts [options]848849Options:850--title <text> Article title (auto-extracted from markdown)851--content <text> Article content (use with --image)852--html <path> HTML file to paste (alternative to --content)853--markdown <path> Markdown file to convert and post (recommended)854--theme <name> Theme for markdown (default, grace, simple, modern)855--color <name|hex> Primary color (blue, green, vermilion, etc. or hex)856--no-cite Disable bottom citations for ordinary external links in markdown mode857--author <name> Author name858--summary <text> Article summary859--image <path> Content image, can repeat (only with --content)860--submit Save as draft861--profile <dir> Chrome profile directory862--account <alias> Select account by alias (for multi-account setups)863--cdp-port <port> Connect to existing Chrome debug port instead of launching new instance864865Examples:866npx -y bun wechat-article.ts --markdown article.md867npx -y bun wechat-article.ts --markdown article.md --theme grace --submit868npx -y bun wechat-article.ts --markdown article.md --no-cite869npx -y bun wechat-article.ts --title "标题" --content "内容" --image img.png870npx -y bun wechat-article.ts --title "标题" --html article.html --submit871872Markdown mode:873Images in markdown are converted to placeholders. After pasting HTML,874each placeholder is selected, scrolled into view, deleted, and replaced875with the actual image via paste. Ordinary external links are converted to876bottom citations by default.877`);878process.exit(0);879}880881async function main(): Promise<void> {882const args = process.argv.slice(2);883if (args.includes('--help') || args.includes('-h')) printUsage();884885const images: string[] = [];886let title: string | undefined;887let content: string | undefined;888let htmlFile: string | undefined;889let markdownFile: string | undefined;890let theme: string | undefined;891let color: string | undefined;892let citeStatus = true;893let author: string | undefined;894let summary: string | undefined;895let submit = false;896let profileDir: string | undefined;897let cdpPort: number | undefined;898let accountAlias: string | undefined;899900for (let i = 0; i < args.length; i++) {901const arg = args[i]!;902if (arg === '--title' && args[i + 1]) title = args[++i];903else if (arg === '--content' && args[i + 1]) content = args[++i];904else if (arg === '--html' && args[i + 1]) htmlFile = args[++i];905else if (arg === '--markdown' && args[i + 1]) markdownFile = args[++i];906else if (arg === '--theme' && args[i + 1]) theme = args[++i];907else if (arg === '--color' && args[i + 1]) color = args[++i];908else if (arg === '--cite') citeStatus = true;909else if (arg === '--no-cite') citeStatus = false;910else if (arg === '--author' && args[i + 1]) author = args[++i];911else if (arg === '--summary' && args[i + 1]) summary = args[++i];912else if (arg === '--image' && args[i + 1]) images.push(args[++i]!);913else if (arg === '--submit') submit = true;914else if (arg === '--profile' && args[i + 1]) profileDir = args[++i];915else if (arg === '--account' && args[i + 1]) accountAlias = args[++i];916else if (arg === '--cdp-port' && args[i + 1]) cdpPort = parseInt(args[++i]!, 10);917}918919const extConfig = loadWechatExtendConfig();920const resolved = resolveAccount(extConfig, accountAlias);921if (resolved.name) console.log(`[wechat] Account: ${resolved.name} (${resolved.alias})`);922923if (!author && resolved.default_author) author = resolved.default_author;924925if (!profileDir && resolved.alias) {926profileDir = resolved.chrome_profile_path || getAccountProfileDir(resolved.alias);927}928929if (!markdownFile && !htmlFile && !title) { console.error('Error: --title is required (or use --markdown/--html)'); process.exit(1); }930if (!markdownFile && !htmlFile && !content) { console.error('Error: --content, --html, or --markdown is required'); process.exit(1); }931932await postArticle({ title: title || '', content, htmlFile, markdownFile, theme, color, citeStatus, author, summary, images, submit, profileDir, cdpPort });933}934935await main().then(() => {936process.exit(0);937}).catch((err) => {938console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);939process.exit(1);940});941