Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 160 additions & 40 deletions skills/liuhedev/baoyu-post-to-x/scripts/x-browser.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import { mkdir } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import {
CHROME_CANDIDATES_FULL,
Expand Down Expand Up @@ -105,62 +106,181 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
}

for (const imagePath of images) {
if (!fs.existsSync(imagePath)) {
console.warn(`[x-browser] Image not found: ${imagePath}`);
const absImagePath = path.isAbsolute(imagePath) ? imagePath : path.resolve(process.cwd(), imagePath);
if (!fs.existsSync(absImagePath)) {
console.warn(`[x-browser] Image not found: ${absImagePath}`);
continue;
}

console.log(`[x-browser] Pasting image: ${imagePath}`);
console.log(`[x-browser] Uploading image: ${absImagePath}`);

if (!copyImageToClipboard(imagePath)) {
console.warn(`[x-browser] Failed to copy image to clipboard: ${imagePath}`);
continue;
// Count uploaded images before upload
const imgCountBefore = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {
expression: `document.querySelectorAll('img[src^="blob:"]').length`,
returnByValue: true,
}, { sessionId });

// Primary method: Use DOM.setFileInputFiles on X's hidden file input (most reliable)
// This is the same approach used by x-video.ts and x-article.ts
let fileInputUsed = false;
try {
await cdp.send('DOM.enable', {}, { sessionId });
const { root } = await cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {}, { sessionId });
const { nodeId } = await cdp.send<{ nodeId: number }>('DOM.querySelector', {
nodeId: root.nodeId,
selector: 'input[data-testid="fileInput"], input[type="file"][accept*="image"], input[type="file"]',
}, { sessionId });

if (nodeId && nodeId !== 0) {
await cdp.send('DOM.setFileInputFiles', {
nodeId,
files: [absImagePath],
}, { sessionId });
fileInputUsed = true;
console.log('[x-browser] Image set via file input');
} else {
console.log('[x-browser] No file input found, falling back to clipboard paste');
}
} catch (err) {
console.log(`[x-browser] File input method failed: ${err instanceof Error ? err.message : String(err)}, falling back to clipboard paste`);
}

// Wait for clipboard to be ready
await sleep(500);
// Fallback: clipboard paste (requires Accessibility permissions on macOS)
if (!fileInputUsed) {
if (!copyImageToClipboard(absImagePath)) {
console.warn(`[x-browser] Failed to copy image to clipboard: ${absImagePath}`);
continue;
}

// Focus the editor
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetTextarea_0"]')?.focus()`,
}, { sessionId });
await sleep(200);

// 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,
await sleep(500);

await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetTextarea_0"]')?.focus()`,
}, { sessionId });
await cdp.send('Input.dispatchKeyEvent', {
type: 'keyUp',
key: 'v',
code: 'KeyV',
modifiers,
windowsVirtualKeyCode: 86,
await sleep(200);

console.log('[x-browser] Pasting from clipboard...');
const pasteSuccess = pasteFromClipboard('Google Chrome', 5, 500);

if (!pasteSuccess) {
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 });
}
}

// Verify image upload (works for both methods)
console.log('[x-browser] Verifying image upload...');
const expectedImgCount = imgCountBefore.result.value + 1;
let imgUploadOk = false;
const imgWaitStart = Date.now();
while (Date.now() - imgWaitStart < 15_000) {
const r = await cdp!.send<{ result: { value: number } }>('Runtime.evaluate', {
expression: `document.querySelectorAll('img[src^="blob:"]').length`,
returnByValue: true,
}, { sessionId });
if (r.result.value >= expectedImgCount) {
imgUploadOk = true;
break;
}
await sleep(1000);
}

console.log('[x-browser] Waiting for image upload...');
await sleep(4000);
if (imgUploadOk) {
console.log('[x-browser] Image upload verified');
} else {
console.warn('[x-browser] Image upload not detected after 15s');
}
}

if (submit) {
console.log('[x-browser] Submitting post...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,

// Wait for post button to be enabled (image may still be processing)
const btnCheck = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
expression: `(() => {
const btn = document.querySelector('[data-testid="tweetButton"]');
if (!btn) return 'not_found';
if (btn.getAttribute('aria-disabled') === 'true' || btn.disabled) return 'disabled';
return 'ready';
})()`,
returnByValue: true,
}, { sessionId });

if (btnCheck.result.value === 'disabled') {
console.log('[x-browser] Post button disabled (image still uploading), waiting...');
const btnWaitStart = Date.now();
while (Date.now() - btnWaitStart < 30_000) {
await sleep(1000);
const r = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `(() => {
const btn = document.querySelector('[data-testid="tweetButton"]');
return btn && btn.getAttribute('aria-disabled') !== 'true' && !btn.disabled;
})()`,
returnByValue: true,
}, { sessionId });
if (r.result.value) break;
}
}

// Use real CDP mouse click (X may block synthetic JS .click())
const btnPos = await cdp.send<{ result: { value: { x: number; y: number } | null } }>('Runtime.evaluate', {
expression: `(() => {
const btn = document.querySelector('[data-testid="tweetButton"]');
if (!btn) return null;
const r = btn.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
})()`,
returnByValue: true,
}, { sessionId });
await sleep(2000);
console.log('[x-browser] Post submitted!');

if (btnPos.result.value) {
const { x, y } = btnPos.result.value;
await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, { sessionId });
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, { sessionId });
} else {
console.warn('[x-browser] Post button not found, trying JS click fallback');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,
}, { sessionId });
}

// Wait for the compose dialog to close (confirms post was sent)
console.log('[x-browser] Waiting for post confirmation...');
let postConfirmed = false;
const confirmStart = Date.now();
while (Date.now() - confirmStart < 15_000) {
await sleep(1000);
const r = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!document.querySelector('[data-testid="tweetTextarea_0"]')`,
returnByValue: true,
}, { sessionId });
if (r.result.value) {
postConfirmed = true;
break;
}
}

if (postConfirmed) {
console.log('[x-browser] Post submitted and confirmed!');
} else {
// Dialog may still close after a delay — post likely went through
console.log('[x-browser] Post button clicked. Waiting for X to process...');
await sleep(5000);
}
} else {
console.log('[x-browser] Post composed (preview mode). Add --submit to post.');
console.log('[x-browser] Browser will stay open for 30 seconds for preview...');
Expand Down
Loading