Fix WeChat image upload fallback and WebP clipboard

This commit is contained in:
Jim Liu 宝玉
2026-05-16 22:33:07 -05:00
parent a5d227b4e8
commit 7fd9e51fc2
4 changed files with 151 additions and 5 deletions
@@ -12,3 +12,9 @@ test("macOS image clipboard copy avoids Swift AppKit JIT", () => {
assert.match(clipboardScript, /copyImageMacWithOsascript/);
assert.doesNotMatch(clipboardScript, /await runCommand\('swift', \[swiftPath, 'image', imagePath\]\)/);
});
test("macOS image clipboard copy converts WebP to PNG before AppleScript", () => {
assert.match(clipboardScript, /convertWebpMacToPng/);
assert.match(clipboardScript, /path\.extname\(imagePath\)\.toLowerCase\(\) === '\.webp'/);
assert.match(clipboardScript, /await copyImageMacWithOsascript\(pngPath\)/);
});
@@ -1,9 +1,12 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import decodeWebp, { init as initWebpDecode } from '@jsquash/webp/decode.js';
import { Jimp, JimpMime } from 'jimp';
const SUPPORTED_IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
@@ -205,6 +208,35 @@ function getAppleScriptImageType(imagePath: string): string {
}
}
let webpDecoderReady: Promise<void> | undefined;
async function ensureWebpDecoder(): Promise<void> {
if (!webpDecoderReady) {
webpDecoderReady = (async () => {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const wasmPath = path.resolve(__dirname, 'node_modules/@jsquash/webp/codec/dec/webp_dec.wasm');
const wasmModule = await WebAssembly.compile(await readFile(wasmPath));
await initWebpDecode(wasmModule, {});
})();
}
await webpDecoderReady;
}
async function convertWebpMacToPng(webpPath: string, tempDir: string): Promise<string> {
await ensureWebpDecoder();
const decoded = await decodeWebp(await readFile(webpPath));
const image = new Jimp({
data: Buffer.from(decoded.data.buffer, decoded.data.byteOffset, decoded.data.byteLength),
width: decoded.width,
height: decoded.height,
});
const pngPath = path.join(tempDir, `${path.basename(webpPath, path.extname(webpPath))}.png`);
await writeFile(pngPath, await image.getBuffer(JimpMime.png));
return pngPath;
}
async function copyImageMacWithOsascript(imagePath: string): Promise<void> {
const imageType = getAppleScriptImageType(imagePath);
const escapedPath = escapeAppleScriptString(imagePath);
@@ -215,6 +247,14 @@ async function copyImageMacWithOsascript(imagePath: string): Promise<void> {
}
async function copyImageMac(imagePath: string): Promise<void> {
if (path.extname(imagePath).toLowerCase() === '.webp') {
await withTempDir('copy-to-clipboard-', async (tempDir) => {
const pngPath = await convertWebpMacToPng(imagePath, tempDir);
await copyImageMacWithOsascript(pngPath);
});
return;
}
await copyImageMacWithOsascript(imagePath);
}
@@ -40,6 +40,17 @@ test("browser article inserts inline images through WeChat local upload instead
assert.doesNotMatch(articleScript, /await copyImageToClipboard\(img\.localPath\)/);
});
test("browser article uploads original images before fallback processing", () => {
const rawUploadIndex = articleScript.indexOf("await uploadImagePathThroughFileInput(session, absolutePath, beforeCount)");
const fallbackIndex = articleScript.indexOf("const fallback = await prepareFallbackWechatBodyImageUpload(absolutePath)");
assert.notEqual(rawUploadIndex, -1);
assert.notEqual(fallbackIndex, -1);
assert.ok(rawUploadIndex < fallbackIndex);
assert.match(articleScript, /prepareWechatBodyImageUpload/);
assert.match(articleScript, /Raw image upload failed, retrying with fallback processing/);
});
test("browser article waits for a saved draft appmsgid before reporting success", () => {
assert.match(articleScript, /waitForDraftSaved/);
assert.match(articleScript, /appmsgid/);
@@ -1,10 +1,12 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { launchChrome, tryConnectExisting, findExistingChromeDebugPort, getPageSession, waitForNewTab, clickElement, typeText, evaluate, sleep, getAccountProfileDir, type ChromeSession, type CdpConnection } from './cdp.ts';
import { loadWechatExtendConfig, resolveAccount } from './wechat-extend-config.ts';
import { prepareWechatBodyImageUpload } from './wechat-image-processor.ts';
const WECHAT_URL = 'https://mp.weixin.qq.com/';
const BODY_EDITOR_SELECTOR = '.rich_media_content .ProseMirror';
@@ -651,11 +653,32 @@ async function waitForBodyImageCount(session: ChromeSession, minimumCount: numbe
return false;
}
async function uploadImageThroughFileInput(session: ChromeSession, imagePath: string): Promise<void> {
const absolutePath = path.isAbsolute(imagePath) ? imagePath : path.resolve(process.cwd(), imagePath);
if (!fs.existsSync(absolutePath)) throw new Error(`Image file not found: ${absolutePath}`);
function inferImageContentType(imagePath: string): string {
const ext = path.extname(imagePath).toLowerCase();
switch (ext) {
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.png':
return 'image/png';
case '.gif':
return 'image/gif';
case '.webp':
return 'image/webp';
case '.bmp':
return 'image/bmp';
case '.svg':
return 'image/svg+xml';
default:
return 'application/octet-stream';
}
}
const beforeCount = await getBodyImageCount(session);
async function uploadImagePathThroughFileInput(
session: ChromeSession,
absolutePath: string,
beforeCount: number,
): Promise<void> {
const documentNode = await session.cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {
depth: -1,
pierce: true,
@@ -679,6 +702,72 @@ async function uploadImageThroughFileInput(session: ChromeSession, imagePath: st
}
}
interface FallbackUploadImage {
uploadPath: string;
wasProcessed: boolean;
processingNotes: string[];
cleanup: () => void;
}
async function prepareFallbackWechatBodyImageUpload(absolutePath: string): Promise<FallbackUploadImage> {
const buffer = fs.readFileSync(absolutePath);
const prepared = await prepareWechatBodyImageUpload({
buffer,
filename: path.basename(absolutePath),
contentType: inferImageContentType(absolutePath),
fileExt: path.extname(absolutePath).toLowerCase(),
fileSize: buffer.length,
});
if (!prepared.wasProcessed) {
return {
uploadPath: absolutePath,
wasProcessed: false,
processingNotes: [],
cleanup: () => {},
};
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-body-image-'));
const uploadPath = path.join(tempDir, prepared.filename);
fs.writeFileSync(uploadPath, prepared.buffer);
return {
uploadPath,
wasProcessed: true,
processingNotes: prepared.processingNotes,
cleanup: () => fs.rmSync(tempDir, { recursive: true, force: true }),
};
}
async function uploadImageThroughFileInput(session: ChromeSession, imagePath: string): Promise<void> {
const absolutePath = path.isAbsolute(imagePath) ? imagePath : path.resolve(process.cwd(), imagePath);
if (!fs.existsSync(absolutePath)) throw new Error(`Image file not found: ${absolutePath}`);
const beforeCount = await getBodyImageCount(session);
try {
await uploadImagePathThroughFileInput(session, absolutePath, beforeCount);
return;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes('local image upload input not found')) throw err;
const currentCount = await getBodyImageCount(session);
if (currentCount > beforeCount) return;
console.warn(`[wechat] Raw image upload failed, retrying with fallback processing: ${message}`);
const fallback = await prepareFallbackWechatBodyImageUpload(absolutePath);
const notes = fallback.processingNotes.length > 0 ? ` (${fallback.processingNotes.join('; ')})` : '';
console.log(`[wechat] Retrying image upload with ${fallback.wasProcessed ? 'processed' : 'original'} file: ${path.basename(fallback.uploadPath)}${notes}`);
try {
await uploadImagePathThroughFileInput(session, fallback.uploadPath, currentCount);
} finally {
fallback.cleanup();
}
}
}
interface DraftSaveStatus {
appmsgid: string;
isLoading: boolean;