diff --git a/skills/baoyu-post-to-wechat/scripts/copy-to-clipboard-mac.test.ts b/skills/baoyu-post-to-wechat/scripts/copy-to-clipboard-mac.test.ts index fa94668..54b4a71 100644 --- a/skills/baoyu-post-to-wechat/scripts/copy-to-clipboard-mac.test.ts +++ b/skills/baoyu-post-to-wechat/scripts/copy-to-clipboard-mac.test.ts @@ -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\)/); +}); diff --git a/skills/baoyu-post-to-wechat/scripts/copy-to-clipboard.ts b/skills/baoyu-post-to-wechat/scripts/copy-to-clipboard.ts index 68936d7..4b58309 100644 --- a/skills/baoyu-post-to-wechat/scripts/copy-to-clipboard.ts +++ b/skills/baoyu-post-to-wechat/scripts/copy-to-clipboard.ts @@ -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 | undefined; + +async function ensureWebpDecoder(): Promise { + 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 { + 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 { const imageType = getAppleScriptImageType(imagePath); const escapedPath = escapeAppleScriptString(imagePath); @@ -215,6 +247,14 @@ async function copyImageMacWithOsascript(imagePath: string): Promise { } async function copyImageMac(imagePath: string): Promise { + 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); } diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-article-focus.test.ts b/skills/baoyu-post-to-wechat/scripts/wechat-article-focus.test.ts index 4a2266b..73f85fd 100644 --- a/skills/baoyu-post-to-wechat/scripts/wechat-article-focus.test.ts +++ b/skills/baoyu-post-to-wechat/scripts/wechat-article-focus.test.ts @@ -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/); diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-article.ts b/skills/baoyu-post-to-wechat/scripts/wechat-article.ts index 61a01e4..ddf2a6a 100644 --- a/skills/baoyu-post-to-wechat/scripts/wechat-article.ts +++ b/skills/baoyu-post-to-wechat/scripts/wechat-article.ts @@ -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 { - 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 { 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 { + 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 { + 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;