diff --git a/skills/liuhedev/baoyu-post-to-x/scripts/x-browser.ts b/skills/liuhedev/baoyu-post-to-x/scripts/x-browser.ts index 224a48c5fa4..9e6073e8182 100644 --- a/skills/liuhedev/baoyu-post-to-x/scripts/x-browser.ts +++ b/skills/liuhedev/baoyu-post-to-x/scripts/x-browser.ts @@ -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, @@ -105,62 +106,181 @@ export async function postToX(options: XBrowserOptions): Promise { } 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...');