mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-13 22:29:48 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38cc497748 | |||
| 0f95d12a09 | |||
| 7fd9e51fc2 | |||
| a5d227b4e8 | |||
| 81377416b4 |
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||||
"version": "1.117.0"
|
"version": "1.117.1"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
English | [中文](./CHANGELOG.zh.md)
|
English | [中文](./CHANGELOG.zh.md)
|
||||||
|
|
||||||
|
## 1.117.1 - 2026-05-16
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-post-to-wechat`: fix WeChat browser article publishing (by @zhangga)
|
||||||
|
- `baoyu-post-to-wechat`: fix image upload fallback and WebP clipboard copy on macOS
|
||||||
|
|
||||||
## 1.117.0 - 2026-05-16
|
## 1.117.0 - 2026-05-16
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
[English](./CHANGELOG.md) | 中文
|
[English](./CHANGELOG.md) | 中文
|
||||||
|
|
||||||
|
## 1.117.1 - 2026-05-16
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-post-to-wechat`:修复微信浏览器文章发布问题 (by @zhangga)
|
||||||
|
- `baoyu-post-to-wechat`:修复图片上传回退逻辑及 macOS WebP 剪贴板复制
|
||||||
|
|
||||||
## 1.117.0 - 2026-05-16
|
## 1.117.0 - 2026-05-16
|
||||||
|
|
||||||
### 新功能
|
### 新功能
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const clipboardScript = fs.readFileSync(path.join(__dirname, "copy-to-clipboard.ts"), "utf8");
|
||||||
|
|
||||||
|
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 { spawn } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
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 os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import process from 'node:process';
|
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']);
|
const SUPPORTED_IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
|
||||||
|
|
||||||
@@ -186,12 +189,73 @@ default:
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyImageMac(imagePath: string): Promise<void> {
|
function escapeAppleScriptString(value: string): string {
|
||||||
await withTempDir('copy-to-clipboard-', async (tempDir) => {
|
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
const swiftPath = path.join(tempDir, 'clipboard.swift');
|
}
|
||||||
await writeFile(swiftPath, getMacSwiftClipboardSource(), 'utf8');
|
|
||||||
await runCommand('swift', [swiftPath, 'image', imagePath]);
|
function getAppleScriptImageType(imagePath: string): string {
|
||||||
|
const ext = path.extname(imagePath).toLowerCase();
|
||||||
|
switch (ext) {
|
||||||
|
case '.png':
|
||||||
|
return '«class PNGf»';
|
||||||
|
case '.jpg':
|
||||||
|
case '.jpeg':
|
||||||
|
return 'JPEG picture';
|
||||||
|
case '.gif':
|
||||||
|
return 'GIF picture';
|
||||||
|
default:
|
||||||
|
throw new Error(`macOS clipboard image copy supports PNG, JPEG, and GIF via AppleScript; convert ${ext || 'this file'} to PNG first.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
await runCommand('osascript', [
|
||||||
|
'-e',
|
||||||
|
`set the clipboard to (read (POSIX file "${escapedPath}") as ${imageType})`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyHtmlMac(htmlFilePath: string): Promise<void> {
|
async function copyHtmlMac(htmlFilePath: string): Promise<void> {
|
||||||
@@ -377,4 +441,3 @@ await main().catch((err) => {
|
|||||||
console.error(`Error: ${message}`);
|
console.error(`Error: ${message}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const articleScript = fs.readFileSync(path.join(__dirname, "wechat-article.ts"), "utf8");
|
||||||
|
|
||||||
|
test("browser article paste uses CDP-targeted paste instead of global macOS keystrokes", () => {
|
||||||
|
assert.equal(
|
||||||
|
articleScript.includes('tell application "System Events" to keystroke "v" using command down'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("browser article publishing verifies the title before saving drafts", () => {
|
||||||
|
assert.match(articleScript, /verifyTitleUnchangedBeforeSave/);
|
||||||
|
assert.match(articleScript, /Title was modified during paste/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("browser article body insertion does not paste HTML through the active form field", () => {
|
||||||
|
assert.match(articleScript, /insertHtmlIntoEditorFromFile/);
|
||||||
|
assert.doesNotMatch(articleScript, /await copyHtmlFromBrowser\(cdp, effectiveHtmlFile, contentImages\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("browser article body operations target the body ProseMirror, not the title ProseMirror", () => {
|
||||||
|
assert.match(articleScript, /BODY_EDITOR_SELECTOR = '\.rich_media_content \.ProseMirror'/);
|
||||||
|
assert.doesNotMatch(articleScript, /clickElement\(session, '\\.ProseMirror'\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("browser article reuses the selected account Chrome profile before launching", () => {
|
||||||
|
assert.match(articleScript, /await findExistingChromeDebugPort\(profileDir\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("browser article inserts inline images through WeChat local upload instead of clipboard paste", () => {
|
||||||
|
assert.match(articleScript, /uploadImageThroughFileInput/);
|
||||||
|
assert.match(articleScript, /DOM\.setFileInputFiles/);
|
||||||
|
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/);
|
||||||
|
assert.doesNotMatch(articleScript, /Waiting for save confirmation/);
|
||||||
|
});
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { launchChrome, tryConnectExisting, findExistingChromeDebugPort, getPageSession, waitForNewTab, clickElement, typeText, evaluate, sleep, getAccountProfileDir, type ChromeSession, type CdpConnection } from './cdp.ts';
|
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 { loadWechatExtendConfig, resolveAccount } from './wechat-extend-config.ts';
|
||||||
|
import { prepareWechatBodyImageUpload } from './wechat-image-processor.ts';
|
||||||
|
|
||||||
const WECHAT_URL = 'https://mp.weixin.qq.com/';
|
const WECHAT_URL = 'https://mp.weixin.qq.com/';
|
||||||
|
const BODY_EDITOR_SELECTOR = '.rich_media_content .ProseMirror';
|
||||||
|
|
||||||
interface ImageInfo {
|
interface ImageInfo {
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
@@ -196,27 +199,27 @@ async function pasteInEditor(session: ChromeSession): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sendCopy(cdp?: CdpConnection, sessionId?: string): Promise<void> {
|
async function sendCopy(cdp?: CdpConnection, sessionId?: string): Promise<void> {
|
||||||
if (process.platform === 'darwin') {
|
if (cdp && sessionId) {
|
||||||
|
const modifiers = process.platform === 'darwin' ? 4 : 2;
|
||||||
|
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'c', code: 'KeyC', modifiers, windowsVirtualKeyCode: 67 }, { sessionId });
|
||||||
|
await sleep(50);
|
||||||
|
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'c', code: 'KeyC', modifiers, windowsVirtualKeyCode: 67 }, { sessionId });
|
||||||
|
} else if (process.platform === 'darwin') {
|
||||||
spawnSync('osascript', ['-e', 'tell application "System Events" to keystroke "c" using command down']);
|
spawnSync('osascript', ['-e', 'tell application "System Events" to keystroke "c" using command down']);
|
||||||
} else if (process.platform === 'linux') {
|
} else if (process.platform === 'linux') {
|
||||||
spawnSync('xdotool', ['key', 'ctrl+c']);
|
spawnSync('xdotool', ['key', 'ctrl+c']);
|
||||||
} else if (cdp && sessionId) {
|
|
||||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'c', code: 'KeyC', modifiers: 2, windowsVirtualKeyCode: 67 }, { sessionId });
|
|
||||||
await sleep(50);
|
|
||||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'c', code: 'KeyC', modifiers: 2, windowsVirtualKeyCode: 67 }, { sessionId });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendPaste(cdp?: CdpConnection, sessionId?: string): Promise<void> {
|
async function sendPaste(cdp?: CdpConnection, sessionId?: string): Promise<void> {
|
||||||
if (process.platform === 'darwin') {
|
if (!cdp || !sessionId) {
|
||||||
spawnSync('osascript', ['-e', 'tell application "System Events" to keystroke "v" using command down']);
|
throw new Error('Targeted paste requires a Chrome DevTools session');
|
||||||
} else if (process.platform === 'linux') {
|
|
||||||
spawnSync('xdotool', ['key', 'ctrl+v']);
|
|
||||||
} else if (cdp && sessionId) {
|
|
||||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'v', code: 'KeyV', modifiers: 2, windowsVirtualKeyCode: 86 }, { sessionId });
|
|
||||||
await sleep(50);
|
|
||||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'v', code: 'KeyV', modifiers: 2, windowsVirtualKeyCode: 86 }, { sessionId });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modifiers = process.platform === 'darwin' ? 4 : 2;
|
||||||
|
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId });
|
||||||
|
await sleep(50);
|
||||||
|
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyHtmlFromBrowser(cdp: CdpConnection, htmlFilePath: string, contentImages: ImageInfo[] = []): Promise<void> {
|
async function copyHtmlFromBrowser(cdp: CdpConnection, htmlFilePath: string, contentImages: ImageInfo[] = []): Promise<void> {
|
||||||
@@ -294,6 +297,82 @@ async function pasteFromClipboardInEditor(session: ChromeSession): Promise<void>
|
|||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function insertHtmlIntoEditorFromFile(
|
||||||
|
session: ChromeSession,
|
||||||
|
htmlFilePath: string,
|
||||||
|
contentImages: ImageInfo[] = [],
|
||||||
|
): Promise<void> {
|
||||||
|
const absolutePath = path.isAbsolute(htmlFilePath) ? htmlFilePath : path.resolve(process.cwd(), htmlFilePath);
|
||||||
|
const html = fs.readFileSync(absolutePath, 'utf8');
|
||||||
|
const replacements = contentImages.map(img => ({ placeholder: img.placeholder, localPath: img.localPath }));
|
||||||
|
|
||||||
|
const result = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||||
|
expression: `
|
||||||
|
(function() {
|
||||||
|
const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
|
||||||
|
if (!editor) return JSON.stringify({ ok: false, reason: 'editor-missing' });
|
||||||
|
|
||||||
|
const template = document.createElement('template');
|
||||||
|
template.innerHTML = ${JSON.stringify(html)};
|
||||||
|
const replacements = ${JSON.stringify(replacements)};
|
||||||
|
|
||||||
|
for (const img of Array.from(template.content.querySelectorAll('img'))) {
|
||||||
|
const src = img.getAttribute('src') || '';
|
||||||
|
const localPath = img.getAttribute('data-local-path') || '';
|
||||||
|
const replacement = replacements.find((item) => item.placeholder === src || item.localPath === localPath);
|
||||||
|
if (replacement) {
|
||||||
|
img.replaceWith(document.createTextNode(replacement.placeholder));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = template.content.querySelector('#output');
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
if (output) {
|
||||||
|
wrapper.innerHTML = output.innerHTML;
|
||||||
|
} else {
|
||||||
|
wrapper.appendChild(template.content.cloneNode(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.focus();
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(editor);
|
||||||
|
range.deleteContents();
|
||||||
|
range.collapse(true);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
|
||||||
|
const inserted = document.execCommand('insertHTML', false, wrapper.innerHTML);
|
||||||
|
editor.dispatchEvent(new InputEvent('input', {
|
||||||
|
bubbles: true,
|
||||||
|
inputType: 'insertHTML',
|
||||||
|
data: wrapper.innerText || ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
ok: inserted || (editor.innerText || '').trim().length > 0,
|
||||||
|
textLength: (editor.innerText || '').trim().length
|
||||||
|
});
|
||||||
|
})()
|
||||||
|
`,
|
||||||
|
returnByValue: true,
|
||||||
|
}, { sessionId: session.sessionId });
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result.result.value || '{}') as { ok?: boolean; reason?: string; textLength?: number };
|
||||||
|
if (!parsed.ok) {
|
||||||
|
throw new Error(`Failed to insert HTML into body editor${parsed.reason ? `: ${parsed.reason}` : ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyTitleUnchangedBeforeSave(session: ChromeSession, expectedTitle: string): Promise<void> {
|
||||||
|
if (!expectedTitle) return;
|
||||||
|
|
||||||
|
const actualTitle = await evaluate<string>(session, `document.querySelector('#title')?.value || ''`);
|
||||||
|
if (actualTitle !== expectedTitle) {
|
||||||
|
throw new Error(`Title was modified during paste. Expected: "${expectedTitle}", got: "${actualTitle}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function prepareEditorPasteTarget(
|
async function prepareEditorPasteTarget(
|
||||||
session: ChromeSession,
|
session: ChromeSession,
|
||||||
context: string,
|
context: string,
|
||||||
@@ -303,19 +382,24 @@ async function prepareEditorPasteTarget(
|
|||||||
await sleep(100);
|
await sleep(100);
|
||||||
|
|
||||||
if (options.clickEditor) {
|
if (options.clickEditor) {
|
||||||
await clickElement(session, '.ProseMirror');
|
await clickElement(session, BODY_EDITOR_SELECTOR);
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ready = await evaluate<boolean>(session, `
|
const ready = await evaluate<boolean>(session, `
|
||||||
(function() {
|
(function() {
|
||||||
const editor = document.querySelector('.ProseMirror');
|
const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
|
||||||
if (!editor) return false;
|
if (!editor) return false;
|
||||||
|
|
||||||
const active = document.activeElement;
|
const active = document.activeElement;
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
const selectionInEditor = !!selection && selection.rangeCount > 0 && !!selection.anchorNode && editor.contains(selection.anchorNode);
|
const selectionInEditor = !!selection && selection.rangeCount > 0 && !!selection.anchorNode && editor.contains(selection.anchorNode);
|
||||||
const focusInEditor = !!active && (active === editor || editor.contains(active));
|
const focusInEditor = !!active && (active === editor || editor.contains(active));
|
||||||
|
const activeIsUnsafeInput = !!active && (
|
||||||
|
active.matches?.('#title, #author, #js_description') ||
|
||||||
|
((active.tagName === 'INPUT' || active.tagName === 'TEXTAREA') && !editor.contains(active))
|
||||||
|
);
|
||||||
|
if (activeIsUnsafeInput) return false;
|
||||||
if (selectionInEditor || focusInEditor) return true;
|
if (selectionInEditor || focusInEditor) return true;
|
||||||
|
|
||||||
if (${JSON.stringify(Boolean(options.clickEditor))}) {
|
if (${JSON.stringify(Boolean(options.clickEditor))}) {
|
||||||
@@ -438,7 +522,7 @@ async function selectAndReplacePlaceholder(session: ChromeSession, placeholder:
|
|||||||
const result = await session.cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
|
const result = await session.cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
|
||||||
expression: `
|
expression: `
|
||||||
(function() {
|
(function() {
|
||||||
const editor = document.querySelector('.ProseMirror');
|
const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
|
||||||
if (!editor) return false;
|
if (!editor) return false;
|
||||||
|
|
||||||
const placeholder = ${JSON.stringify(placeholder)};
|
const placeholder = ${JSON.stringify(placeholder)};
|
||||||
@@ -456,6 +540,7 @@ async function selectAndReplacePlaceholder(session: ChromeSession, placeholder:
|
|||||||
// Exact match if next char is not a digit
|
// Exact match if next char is not a digit
|
||||||
if (charAfter === undefined || !/\\d/.test(charAfter)) {
|
if (charAfter === undefined || !/\\d/.test(charAfter)) {
|
||||||
node.parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
node.parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
editor.focus();
|
||||||
|
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
range.setStart(node, idx);
|
range.setStart(node, idx);
|
||||||
@@ -486,7 +571,7 @@ async function pressDeleteKey(session: ChromeSession): Promise<void> {
|
|||||||
async function removeExtraEmptyLineAfterImage(session: ChromeSession): Promise<boolean> {
|
async function removeExtraEmptyLineAfterImage(session: ChromeSession): Promise<boolean> {
|
||||||
const removed = await evaluate<boolean>(session, `
|
const removed = await evaluate<boolean>(session, `
|
||||||
(function() {
|
(function() {
|
||||||
const editor = document.querySelector('.ProseMirror');
|
const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
|
||||||
if (!editor) return false;
|
if (!editor) return false;
|
||||||
|
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
@@ -548,6 +633,188 @@ async function removeExtraEmptyLineAfterImage(session: ChromeSession): Promise<b
|
|||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getBodyImageCount(session: ChromeSession): Promise<number> {
|
||||||
|
return await evaluate<number>(session, `
|
||||||
|
(function() {
|
||||||
|
const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
|
||||||
|
if (!editor) return 0;
|
||||||
|
return Array.from(editor.querySelectorAll('img')).filter((img) => !img.classList.contains('ProseMirror-separator')).length;
|
||||||
|
})()
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForBodyImageCount(session: ChromeSession, minimumCount: number, timeoutMs = 45_000): Promise<boolean> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
const count = await getBodyImageCount(session);
|
||||||
|
if (count >= minimumCount) return true;
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}, { sessionId: session.sessionId });
|
||||||
|
const inputNode = await session.cdp.send<{ nodeId: number }>('DOM.querySelector', {
|
||||||
|
nodeId: documentNode.root.nodeId,
|
||||||
|
selector: 'input[type="file"][accept*="image"]',
|
||||||
|
}, { sessionId: session.sessionId });
|
||||||
|
|
||||||
|
if (!inputNode.nodeId) throw new Error('WeChat local image upload input not found');
|
||||||
|
|
||||||
|
await session.cdp.send('DOM.setFileInputFiles', {
|
||||||
|
nodeId: inputNode.nodeId,
|
||||||
|
files: [absolutePath],
|
||||||
|
}, { sessionId: session.sessionId });
|
||||||
|
|
||||||
|
const inserted = await waitForBodyImageCount(session, beforeCount + 1);
|
||||||
|
if (!inserted) {
|
||||||
|
const afterCount = await getBodyImageCount(session);
|
||||||
|
throw new Error(`Image upload did not insert into editor: ${path.basename(absolutePath)} (${beforeCount} -> ${afterCount})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
submitText: string;
|
||||||
|
url: string;
|
||||||
|
messages: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDraftSaveStatus(session: ChromeSession): Promise<DraftSaveStatus> {
|
||||||
|
const raw = await evaluate<string>(session, `
|
||||||
|
(function() {
|
||||||
|
const submit = document.querySelector('#js_submit');
|
||||||
|
const button = submit?.querySelector('button');
|
||||||
|
const url = location.href;
|
||||||
|
const appmsgid = new URL(url).searchParams.get('appmsgid') || '';
|
||||||
|
const messages = Array.from(document.querySelectorAll('.weui-desktop-toast, .weui-desktop-toptips, .js_tips'))
|
||||||
|
.map((el) => (el.innerText || el.textContent || '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return JSON.stringify({
|
||||||
|
appmsgid,
|
||||||
|
isLoading: !!submit?.classList.contains('btn_loading') || !!button?.disabled,
|
||||||
|
submitText: (submit?.innerText || '').trim(),
|
||||||
|
url,
|
||||||
|
messages
|
||||||
|
});
|
||||||
|
})()
|
||||||
|
`);
|
||||||
|
return JSON.parse(raw || '{}') as DraftSaveStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForDraftSaved(session: ChromeSession, timeoutMs = 60_000): Promise<string> {
|
||||||
|
const start = Date.now();
|
||||||
|
let lastStatus: DraftSaveStatus | null = null;
|
||||||
|
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
lastStatus = await getDraftSaveStatus(session);
|
||||||
|
if (lastStatus.appmsgid && !lastStatus.isLoading) return lastStatus.appmsgid;
|
||||||
|
|
||||||
|
const relevantFailure = lastStatus.messages.find((message) => /保存.*失败|草稿.*失败|save.*fail/i.test(message));
|
||||||
|
if (relevantFailure) throw new Error(`Draft save failed: ${relevantFailure}`);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Draft save did not complete${lastStatus ? `: ${JSON.stringify(lastStatus)}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function postArticle(options: ArticleOptions): Promise<void> {
|
export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||||
const { title, content, htmlFile, markdownFile, theme, color, citeStatus = true, author, summary, images = [], submit = false, profileDir, cdpPort } = options;
|
const { title, content, htmlFile, markdownFile, theme, color, citeStatus = true, author, summary, images = [], submit = false, profileDir, cdpPort } = options;
|
||||||
let { contentImages = [] } = options;
|
let { contentImages = [] } = options;
|
||||||
@@ -591,7 +858,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
|||||||
let chrome: ReturnType<typeof import('node:child_process').spawn> | null = null;
|
let chrome: ReturnType<typeof import('node:child_process').spawn> | null = null;
|
||||||
|
|
||||||
// Try connecting to existing Chrome: explicit port > auto-detect > launch new
|
// Try connecting to existing Chrome: explicit port > auto-detect > launch new
|
||||||
const portToTry = cdpPort ?? await findExistingChromeDebugPort();
|
const portToTry = cdpPort ?? await findExistingChromeDebugPort(profileDir);
|
||||||
if (portToTry) {
|
if (portToTry) {
|
||||||
const existing = await tryConnectExisting(portToTry);
|
const existing = await tryConnectExisting(portToTry);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -680,7 +947,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
|||||||
console.log('[wechat] Waiting for editor to load...');
|
console.log('[wechat] Waiting for editor to load...');
|
||||||
const editorLoaded = await waitForElement(session, '#title', 30_000);
|
const editorLoaded = await waitForElement(session, '#title', 30_000);
|
||||||
if (!editorLoaded) throw new Error('Editor did not load (#title not found)');
|
if (!editorLoaded) throw new Error('Editor did not load (#title not found)');
|
||||||
await waitForElement(session, '.ProseMirror', 15_000);
|
await waitForElement(session, BODY_EDITOR_SELECTOR, 15_000);
|
||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
|
|
||||||
if (effectiveTitle) {
|
if (effectiveTitle) {
|
||||||
@@ -705,25 +972,23 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('[wechat] Clicking on editor...');
|
console.log('[wechat] Clicking on editor...');
|
||||||
await clickElement(session, '.ProseMirror');
|
await clickElement(session, BODY_EDITOR_SELECTOR);
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
|
|
||||||
console.log('[wechat] Ensuring editor focus...');
|
console.log('[wechat] Ensuring editor focus...');
|
||||||
await clickElement(session, '.ProseMirror');
|
await clickElement(session, BODY_EDITOR_SELECTOR);
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
if (effectiveHtmlFile && fs.existsSync(effectiveHtmlFile)) {
|
if (effectiveHtmlFile && fs.existsSync(effectiveHtmlFile)) {
|
||||||
console.log(`[wechat] Copying HTML content from: ${effectiveHtmlFile}`);
|
console.log(`[wechat] Inserting HTML content from: ${effectiveHtmlFile}`);
|
||||||
await copyHtmlFromBrowser(cdp, effectiveHtmlFile, contentImages);
|
|
||||||
await sleep(500);
|
|
||||||
await prepareEditorPasteTarget(session, 'body content paste', { clickEditor: true });
|
await prepareEditorPasteTarget(session, 'body content paste', { clickEditor: true });
|
||||||
console.log('[wechat] Pasting into editor...');
|
await insertHtmlIntoEditorFromFile(session, effectiveHtmlFile, contentImages);
|
||||||
await pasteFromClipboardInEditor(session);
|
|
||||||
await sleep(3000);
|
await sleep(3000);
|
||||||
|
await verifyTitleUnchangedBeforeSave(session, effectiveTitle);
|
||||||
|
|
||||||
const editorHasContent = await evaluate<boolean>(session, `
|
const editorHasContent = await evaluate<boolean>(session, `
|
||||||
(function() {
|
(function() {
|
||||||
const editor = document.querySelector('.ProseMirror');
|
const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
|
||||||
if (!editor) return false;
|
if (!editor) return false;
|
||||||
const text = editor.innerText?.trim() || '';
|
const text = editor.innerText?.trim() || '';
|
||||||
return text.length > 0;
|
return text.length > 0;
|
||||||
@@ -749,18 +1014,15 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
|||||||
|
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
console.log(`[wechat] Copying image: ${path.basename(img.localPath)}`);
|
|
||||||
await copyImageToClipboard(img.localPath);
|
|
||||||
await sleep(300);
|
|
||||||
|
|
||||||
console.log('[wechat] Deleting placeholder with Backspace...');
|
console.log('[wechat] Deleting placeholder with Backspace...');
|
||||||
await pressDeleteKey(session);
|
await pressDeleteKey(session);
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
|
|
||||||
console.log('[wechat] Pasting image...');
|
console.log(`[wechat] Uploading image: ${path.basename(img.localPath)}`);
|
||||||
await prepareEditorPasteTarget(session, 'inline image paste');
|
await prepareEditorPasteTarget(session, 'inline image upload');
|
||||||
await pasteFromClipboardInEditor(session);
|
await uploadImageThroughFileInput(session, img.localPath);
|
||||||
await sleep(3000);
|
await sleep(1000);
|
||||||
|
await verifyTitleUnchangedBeforeSave(session, effectiveTitle);
|
||||||
await removeExtraEmptyLineAfterImage(session);
|
await removeExtraEmptyLineAfterImage(session);
|
||||||
}
|
}
|
||||||
console.log('[wechat] All images inserted.');
|
console.log('[wechat] All images inserted.');
|
||||||
@@ -768,12 +1030,10 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
|||||||
} else if (content) {
|
} else if (content) {
|
||||||
for (const img of images) {
|
for (const img of images) {
|
||||||
if (fs.existsSync(img)) {
|
if (fs.existsSync(img)) {
|
||||||
console.log(`[wechat] Pasting image: ${img}`);
|
console.log(`[wechat] Uploading image: ${img}`);
|
||||||
await copyImageToClipboard(img);
|
await prepareEditorPasteTarget(session, 'leading image upload');
|
||||||
await sleep(500);
|
await uploadImageThroughFileInput(session, img);
|
||||||
await prepareEditorPasteTarget(session, 'leading image paste');
|
await sleep(1000);
|
||||||
await pasteInEditor(session);
|
|
||||||
await sleep(2000);
|
|
||||||
await removeExtraEmptyLineAfterImage(session);
|
await removeExtraEmptyLineAfterImage(session);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -785,7 +1045,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
|||||||
|
|
||||||
const editorHasContent = await evaluate<boolean>(session, `
|
const editorHasContent = await evaluate<boolean>(session, `
|
||||||
(function() {
|
(function() {
|
||||||
const editor = document.querySelector('.ProseMirror');
|
const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
|
||||||
if (!editor) return false;
|
if (!editor) return false;
|
||||||
const text = editor.innerText?.trim() || '';
|
const text = editor.innerText?.trim() || '';
|
||||||
return text.length > 0;
|
return text.length > 0;
|
||||||
@@ -822,17 +1082,12 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await verifyTitleUnchangedBeforeSave(session, effectiveTitle);
|
||||||
|
|
||||||
console.log('[wechat] Saving as draft...');
|
console.log('[wechat] Saving as draft...');
|
||||||
await evaluate(session, `document.querySelector('#js_submit button').click()`);
|
await evaluate(session, `document.querySelector('#js_submit button').click()`);
|
||||||
await sleep(3000);
|
const appmsgid = await waitForDraftSaved(session);
|
||||||
|
console.log(`[wechat] Draft saved successfully! appmsgid: ${appmsgid}`);
|
||||||
const saved = await evaluate<boolean>(session, `!!document.querySelector('.weui-desktop-toast')`);
|
|
||||||
if (saved) {
|
|
||||||
console.log('[wechat] Draft saved successfully!');
|
|
||||||
} else {
|
|
||||||
console.log('[wechat] Waiting for save confirmation...');
|
|
||||||
await sleep(5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[wechat] Done. Browser window left open.');
|
console.log('[wechat] Done. Browser window left open.');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user