Add clipboard paste functionality and documentation for X Articles and regular posts

- Implemented `paste-from-clipboard.ts` script to send real paste keystrokes across macOS, Linux, and Windows.
- Created detailed guides for publishing articles to X Articles and posting regular content, including usage examples and troubleshooting tips.
- Added support for Markdown formatting and image handling in X Articles.
- Documented manual workflows for image pasting and browser interactions using Playwright MCP.
This commit is contained in:
Jim Liu 宝玉
2026-01-15 15:38:03 -06:00
parent da920bb830
commit e3d2f5d03f
13 changed files with 863 additions and 359 deletions
+10 -1
View File
@@ -303,6 +303,7 @@ export async function parseMarkdown(
// Resolve image paths (download remote, resolve relative)
const contentImages: ImageInfo[] = [];
let isFirstImage = true;
let coverPlaceholder: string | null = null;
for (let i = 0; i < images.length; i++) {
const img = images[i]!;
@@ -311,6 +312,7 @@ export async function parseMarkdown(
// First image becomes cover if no cover specified
if (isFirstImage && !coverImagePath) {
coverImagePath = localPath;
coverPlaceholder = `[[IMAGE_PLACEHOLDER_${i + 1}]]`;
isFirstImage = false;
// Don't add to contentImages, it's the cover
continue;
@@ -325,6 +327,13 @@ export async function parseMarkdown(
});
}
// Remove cover placeholder from HTML if first image was used as cover
let finalHtml = html;
if (coverPlaceholder) {
// Remove the placeholder and its containing <p> tag
finalHtml = finalHtml.replace(new RegExp(`<p>${coverPlaceholder.replace(/[[\]]/g, '\\$&')}</p>\\n?`, 'g'), '');
}
// Resolve cover image path
let resolvedCoverImage: string | null = null;
if (coverImagePath) {
@@ -335,7 +344,7 @@ export async function parseMarkdown(
title,
coverImage: resolvedCoverImage,
contentImages,
html,
html: finalHtml,
totalBlocks,
};
}
@@ -0,0 +1,194 @@
import { spawnSync } from 'node:child_process';
import process from 'node:process';
function printUsage(exitCode = 0): never {
console.log(`Send real paste keystroke (Cmd+V / Ctrl+V) to the frontmost application
This bypasses CDP's synthetic events which websites can detect and ignore.
Usage:
npx -y bun paste-from-clipboard.ts [options]
Options:
--retries <n> Number of retry attempts (default: 3)
--delay <ms> Delay between retries in ms (default: 500)
--app <name> Target application to activate first (macOS only)
--help Show this help
Examples:
# Simple paste
npx -y bun paste-from-clipboard.ts
# Paste to Chrome with retries
npx -y bun paste-from-clipboard.ts --app "Google Chrome" --retries 5
# Quick paste with shorter delay
npx -y bun paste-from-clipboard.ts --delay 200
`);
process.exit(exitCode);
}
function sleepSync(ms: number): void {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function activateApp(appName: string): boolean {
if (process.platform !== 'darwin') return false;
// Activate and wait for app to be frontmost
const script = `
tell application "${appName}"
activate
delay 0.5
end tell
-- Verify app is frontmost
tell application "System Events"
set frontApp to name of first application process whose frontmost is true
if frontApp is not "${appName}" then
tell application "${appName}" to activate
delay 0.3
end if
end tell
`;
const result = spawnSync('osascript', ['-e', script], { stdio: 'pipe' });
return result.status === 0;
}
function pasteMac(retries: number, delayMs: number, targetApp?: string): boolean {
for (let i = 0; i < retries; i++) {
// Build script that activates app (if specified) and sends keystroke in one atomic operation
const script = targetApp
? `
tell application "${targetApp}"
activate
end tell
delay 0.3
tell application "System Events"
keystroke "v" using command down
end tell
`
: `
tell application "System Events"
keystroke "v" using command down
end tell
`;
const result = spawnSync('osascript', ['-e', script], { stdio: 'pipe' });
if (result.status === 0) {
return true;
}
const stderr = result.stderr?.toString().trim();
if (stderr) {
console.error(`[paste] osascript error: ${stderr}`);
}
if (i < retries - 1) {
console.error(`[paste] Attempt ${i + 1}/${retries} failed, retrying in ${delayMs}ms...`);
sleepSync(delayMs);
}
}
return false;
}
function pasteLinux(retries: number, delayMs: number): boolean {
// Try xdotool first (X11), then ydotool (Wayland)
const tools = [
{ cmd: 'xdotool', args: ['key', 'ctrl+v'] },
{ cmd: 'ydotool', args: ['key', '29:1', '47:1', '47:0', '29:0'] }, // Ctrl down, V down, V up, Ctrl up
];
for (const tool of tools) {
const which = spawnSync('which', [tool.cmd], { stdio: 'pipe' });
if (which.status !== 0) continue;
for (let i = 0; i < retries; i++) {
const result = spawnSync(tool.cmd, tool.args, { stdio: 'pipe' });
if (result.status === 0) {
return true;
}
if (i < retries - 1) {
console.error(`[paste] Attempt ${i + 1}/${retries} failed, retrying in ${delayMs}ms...`);
sleepSync(delayMs);
}
}
return false;
}
console.error('[paste] No supported tool found. Install xdotool (X11) or ydotool (Wayland).');
return false;
}
function pasteWindows(retries: number, delayMs: number): boolean {
const ps = `
Add-Type -AssemblyName System.Windows.Forms
[System.Windows.Forms.SendKeys]::SendWait("^v")
`;
for (let i = 0; i < retries; i++) {
const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', ps], { stdio: 'pipe' });
if (result.status === 0) {
return true;
}
if (i < retries - 1) {
console.error(`[paste] Attempt ${i + 1}/${retries} failed, retrying in ${delayMs}ms...`);
sleepSync(delayMs);
}
}
return false;
}
function paste(retries: number, delayMs: number, targetApp?: string): boolean {
switch (process.platform) {
case 'darwin':
return pasteMac(retries, delayMs, targetApp);
case 'linux':
return pasteLinux(retries, delayMs);
case 'win32':
return pasteWindows(retries, delayMs);
default:
console.error(`[paste] Unsupported platform: ${process.platform}`);
return false;
}
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
let retries = 3;
let delayMs = 500;
let targetApp: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i] ?? '';
if (arg === '--help' || arg === '-h') {
printUsage(0);
}
if (arg === '--retries' && args[i + 1]) {
retries = parseInt(args[++i]!, 10) || 3;
} else if (arg === '--delay' && args[i + 1]) {
delayMs = parseInt(args[++i]!, 10) || 500;
} else if (arg === '--app' && args[i + 1]) {
targetApp = args[++i];
} else if (arg.startsWith('-')) {
console.error(`Unknown option: ${arg}`);
printUsage(1);
}
}
if (targetApp) {
console.log(`[paste] Target app: ${targetApp}`);
}
console.log(`[paste] Sending paste keystroke (retries=${retries}, delay=${delayMs}ms)...`);
const success = paste(retries, delayMs, targetApp);
if (success) {
console.log('[paste] Paste keystroke sent successfully');
} else {
console.error('[paste] Failed to send paste keystroke');
process.exit(1);
}
}
await main();
+24 -44
View File
@@ -141,44 +141,30 @@ class CdpConnection {
}
}
function getScriptDir(): string {
return path.dirname(new URL(import.meta.url).pathname);
}
function copyImageToClipboard(imagePath: string): boolean {
const scriptDir = path.dirname(new URL(import.meta.url).pathname);
const copyScript = path.join(scriptDir, 'copy-to-clipboard.ts');
const copyScript = path.join(getScriptDir(), 'copy-to-clipboard.ts');
const result = spawnSync('npx', ['-y', 'bun', copyScript, 'image', imagePath], { stdio: 'inherit' });
return result.status === 0;
}
function copyHtmlToClipboard(htmlPath: string): boolean {
const scriptDir = path.dirname(new URL(import.meta.url).pathname);
const copyScript = path.join(scriptDir, 'copy-to-clipboard.ts');
const copyScript = path.join(getScriptDir(), 'copy-to-clipboard.ts');
const result = spawnSync('npx', ['-y', 'bun', copyScript, 'html', '--file', htmlPath], { stdio: 'inherit' });
return result.status === 0;
}
function sendRealPasteKeystroke(retries = 3): boolean {
if (process.platform !== 'darwin') {
console.log('[x-article] Real keystroke only supported on macOS');
return false;
function pasteFromClipboard(targetApp?: string, retries = 3, delayMs = 500): boolean {
const pasteScript = path.join(getScriptDir(), 'paste-from-clipboard.ts');
const args = ['npx', '-y', 'bun', pasteScript, '--retries', String(retries), '--delay', String(delayMs)];
if (targetApp) {
args.push('--app', targetApp);
}
// Use osascript to send Cmd+V to frontmost application (Chrome)
const script = `
tell application "System Events"
keystroke "v" using command down
end tell
`;
for (let i = 0; i < retries; i++) {
const result = spawnSync('osascript', ['-e', script], { stdio: 'pipe' });
if (result.status === 0) {
return true;
}
// Wait a bit before retry
if (i < retries - 1) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500);
}
}
return false;
const result = spawnSync(args[0]!, args.slice(1), { stdio: 'inherit' });
return result.status === 0;
}
interface ArticleOptions {
@@ -300,11 +286,6 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
}, { sessionId });
};
const pasteFromClipboard = async (): Promise<void> => {
const modifiers = process.platform === 'darwin' ? 4 : 2; // Meta or Ctrl
await pressKey('v', modifiers);
};
// Check if we're on the articles list page (has Write button)
console.log('[x-article] Looking for Write button...');
const writeButtonFound = await waitForElement('[data-testid="empty_state_button_text"]', 10_000);
@@ -538,7 +519,7 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
}, { sessionId });
// Wait for scroll animation
await sleep(300);
await sleep(500);
if (!placeholderFound.result.value) {
console.warn(`[x-article] Placeholder not found in DOM: ${img.placeholder}`);
@@ -553,25 +534,26 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
continue;
}
await sleep(300);
// Wait for clipboard to be fully ready
await sleep(800);
// Delete placeholder by pressing Enter (placeholder is already selected)
console.log(`[x-article] Deleting placeholder with Enter...`);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId });
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId });
await sleep(200);
await sleep(300);
// Paste image
// Paste image using paste script (activates Chrome, sends real keystroke)
console.log(`[x-article] Pasting image...`);
if (sendRealPasteKeystroke()) {
if (pasteFromClipboard('Google Chrome', 5, 800)) {
console.log(`[x-article] Image pasted: ${path.basename(img.localPath)}`);
} else {
console.warn(`[x-article] Failed to paste image`);
console.warn(`[x-article] Failed to paste image after retries`);
}
// Wait for image to upload
console.log(`[x-article] Waiting for upload...`);
await sleep(4000);
await sleep(5000);
}
console.log('[x-article] All images processed.');
@@ -633,17 +615,15 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
console.log('[x-article] Article published!');
} else {
console.log('[x-article] Article composed (draft mode).');
console.log('[x-article] Browser will stay open for 60 seconds for review...');
await sleep(60_000);
console.log('[x-article] Browser remains open for manual review.');
}
} finally {
// Disconnect CDP but keep browser open
if (cdp) {
try { await cdp.send('Browser.close', {}, { timeoutMs: 5_000 }); } catch {}
cdp.close();
}
setTimeout(() => { if (!chrome.killed) try { chrome.kill('SIGKILL'); } catch {} }, 2_000).unref?.();
try { chrome.kill('SIGTERM'); } catch {}
// Don't kill Chrome - let user review and close manually
}
}
+50 -21
View File
@@ -8,6 +8,26 @@ import process from 'node:process';
const X_COMPOSE_URL = 'https://x.com/compose/post';
function getScriptDir(): string {
return path.dirname(new URL(import.meta.url).pathname);
}
function copyImageToClipboard(imagePath: string): boolean {
const copyScript = path.join(getScriptDir(), 'copy-to-clipboard.ts');
const result = spawnSync('npx', ['-y', 'bun', copyScript, 'image', imagePath], { stdio: 'inherit' });
return result.status === 0;
}
function pasteFromClipboard(targetApp?: string, retries = 3, delayMs = 500): boolean {
const pasteScript = path.join(getScriptDir(), 'paste-from-clipboard.ts');
const args = ['npx', '-y', 'bun', pasteScript, '--retries', String(retries), '--delay', String(delayMs)];
if (targetApp) {
args.push('--app', targetApp);
}
const result = spawnSync(args[0]!, args.slice(1), { stdio: 'inherit' });
return result.status === 0;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -274,37 +294,46 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
console.log(`[x-browser] Pasting image: ${imagePath}`);
const scriptDir = path.dirname(new URL(import.meta.url).pathname);
const copyScript = path.join(scriptDir, 'copy-to-clipboard.ts');
const result = spawnSync('npx', ['-y', 'bun', copyScript, 'image', imagePath], { stdio: 'inherit' });
if (result.status !== 0) {
if (!copyImageToClipboard(imagePath)) {
console.warn(`[x-browser] Failed to copy image to clipboard: ${imagePath}`);
continue;
}
// Wait for clipboard to be ready
await sleep(500);
// Focus the editor
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetTextarea_0"]')?.focus()`,
}, { sessionId });
await sleep(200);
const modifiers = process.platform === 'darwin' ? 4 : 2;
await cdp.send('Input.dispatchKeyEvent', {
type: 'keyDown',
key: 'v',
code: 'KeyV',
modifiers,
windowsVirtualKeyCode: 86,
}, { sessionId });
await cdp.send('Input.dispatchKeyEvent', {
type: 'keyUp',
key: 'v',
code: 'KeyV',
modifiers,
windowsVirtualKeyCode: 86,
}, { sessionId });
// Use paste script (handles platform differences, activates Chrome)
console.log('[x-browser] Pasting from clipboard...');
const pasteSuccess = pasteFromClipboard('Google Chrome', 5, 500);
if (!pasteSuccess) {
// Fallback to CDP (may not work for images on X)
console.log('[x-browser] Paste script failed, trying CDP fallback...');
const modifiers = process.platform === 'darwin' ? 4 : 2;
await cdp.send('Input.dispatchKeyEvent', {
type: 'keyDown',
key: 'v',
code: 'KeyV',
modifiers,
windowsVirtualKeyCode: 86,
}, { sessionId });
await cdp.send('Input.dispatchKeyEvent', {
type: 'keyUp',
key: 'v',
code: 'KeyV',
modifiers,
windowsVirtualKeyCode: 86,
}, { sessionId });
}
console.log('[x-browser] Waiting for image upload...');
await sleep(3000);
await sleep(4000);
}
if (submit) {