Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Post text, images, videos, and long-form articles to X (Twitter) via real Chrome browser automation.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/x-browser.ts
1import fs from 'node:fs';2import { mkdir } from 'node:fs/promises';3import process from 'node:process';4import {5CHROME_CANDIDATES_FULL,6CdpConnection,7copyImageToClipboard,8findExistingChromeDebugPort,9getDefaultProfileDir,10gracefulKillChrome,11launchChrome,12openPageSession,13pasteFromClipboard,14sleep,15waitForXSessionPersistence,16waitForChromeDebugPort,17} from './x-utils.js';1819const X_COMPOSE_URL = 'https://x.com/compose/post';2021interface XBrowserOptions {22text?: string;23images?: string[];24submit?: boolean;25timeoutMs?: number;26profileDir?: string;27chromePath?: string;28}2930export async function postToX(options: XBrowserOptions): Promise<void> {31const { text, images = [], submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;3233await mkdir(profileDir, { recursive: true });3435const existingPort = await findExistingChromeDebugPort(profileDir);36const reusing = existingPort !== null;37let port = existingPort ?? 0;38let chrome: Awaited<ReturnType<typeof launchChrome>>['chrome'] | null = null;39if (!reusing) {40const launched = await launchChrome(X_COMPOSE_URL, profileDir, CHROME_CANDIDATES_FULL, options.chromePath);41port = launched.port;42chrome = launched.chrome;43}4445if (reusing) console.log(`[x-browser] Reusing existing Chrome on port ${port}`);46else console.log(`[x-browser] Launching Chrome (profile: ${profileDir})`);4748let cdp: CdpConnection | null = null;49let sessionId: string | null = null;50let loggedInDuringRun = false;5152try {53const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });54cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 15_000 });5556const page = await openPageSession({57cdp,58reusing,59url: X_COMPOSE_URL,60matchTarget: (target) => target.type === 'page' && target.url.includes('x.com'),61enablePage: true,62enableRuntime: true,63enableNetwork: true,64});65const activeSessionId = page.sessionId;66sessionId = activeSessionId;67await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId: activeSessionId });6869console.log('[x-browser] Waiting for X editor...');70await sleep(3000);7172const waitForEditor = async (): Promise<boolean> => {73const start = Date.now();74while (Date.now() - start < timeoutMs) {75const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {76expression: `!!document.querySelector('[data-testid="tweetTextarea_0"]')`,77returnByValue: true,78}, { sessionId: activeSessionId });79if (result.result.value) return true;80await sleep(1000);81}82return false;83};8485const editorFound = await waitForEditor();86if (!editorFound) {87console.log('[x-browser] Editor not found. Please log in to X in the browser window.');88console.log('[x-browser] Waiting for login...');89const loggedIn = await waitForEditor();90if (!loggedIn) throw new Error('Timed out waiting for X editor. Please log in first.');91loggedInDuringRun = true;92}9394if (text) {95console.log('[x-browser] Typing text...');96await cdp.send('Runtime.evaluate', {97expression: `98const editor = document.querySelector('[data-testid="tweetTextarea_0"]');99if (editor) {100editor.focus();101document.execCommand('insertText', false, ${JSON.stringify(text)});102}103`,104}, { sessionId: activeSessionId });105await sleep(500);106}107108for (const imagePath of images) {109if (!fs.existsSync(imagePath)) {110console.warn(`[x-browser] Image not found: ${imagePath}`);111continue;112}113114console.log(`[x-browser] Pasting image: ${imagePath}`);115116if (!copyImageToClipboard(imagePath)) {117console.warn(`[x-browser] Failed to copy image to clipboard: ${imagePath}`);118continue;119}120121// Count uploaded images before paste122const imgCountBefore = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {123expression: `document.querySelectorAll('img[src^="blob:"]').length`,124returnByValue: true,125}, { sessionId: activeSessionId });126127// Wait for clipboard to be ready128await sleep(500);129130// Focus the editor131await cdp.send('Runtime.evaluate', {132expression: `document.querySelector('[data-testid="tweetTextarea_0"]')?.focus()`,133}, { sessionId: activeSessionId });134await sleep(200);135136// Use paste script (handles platform differences, activates Chrome)137console.log('[x-browser] Pasting from clipboard...');138const pasteSuccess = pasteFromClipboard('Google Chrome', 5, 500);139140if (!pasteSuccess) {141// Fallback to CDP (may not work for images on X)142console.log('[x-browser] Paste script failed, trying CDP fallback...');143const modifiers = process.platform === 'darwin' ? 4 : 2;144await cdp.send('Input.dispatchKeyEvent', {145type: 'keyDown',146key: 'v',147code: 'KeyV',148modifiers,149windowsVirtualKeyCode: 86,150}, { sessionId: activeSessionId });151await cdp.send('Input.dispatchKeyEvent', {152type: 'keyUp',153key: 'v',154code: 'KeyV',155modifiers,156windowsVirtualKeyCode: 86,157}, { sessionId: activeSessionId });158}159160console.log('[x-browser] Verifying image upload...');161const expectedImgCount = imgCountBefore.result.value + 1;162let imgUploadOk = false;163const imgWaitStart = Date.now();164while (Date.now() - imgWaitStart < 15_000) {165const r = await cdp!.send<{ result: { value: number } }>('Runtime.evaluate', {166expression: `document.querySelectorAll('img[src^="blob:"]').length`,167returnByValue: true,168}, { sessionId: activeSessionId });169if (r.result.value >= expectedImgCount) {170imgUploadOk = true;171break;172}173await sleep(1000);174}175176if (imgUploadOk) {177console.log('[x-browser] Image upload verified');178} else {179console.warn('[x-browser] Image upload not detected after 15s. Run check-paste-permissions.ts to diagnose.');180}181}182183if (submit) {184console.log('[x-browser] Submitting post...');185await cdp.send('Runtime.evaluate', {186expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,187}, { sessionId: activeSessionId });188await sleep(2000);189console.log('[x-browser] Post submitted!');190} else {191console.log('[x-browser] Post composed. Please review and click the publish button in the browser.');192}193} finally {194let leaveChromeOpen = !submit;195if (chrome && submit && loggedInDuringRun && cdp && sessionId) {196console.log('[x-browser] Waiting for X session cookies to persist...');197const sessionReady = await waitForXSessionPersistence({ cdp, sessionId });198if (!sessionReady) {199console.warn('[x-browser] X session cookies not observed yet. Leaving Chrome open so login can finish persisting.');200leaveChromeOpen = true;201}202}203204if (cdp) {205cdp.close();206}207if (chrome) {208if (leaveChromeOpen) {209chrome.unref();210} else {211await gracefulKillChrome(chrome, port);212}213}214}215}216217function printUsage(): never {218console.log(`Post to X (Twitter) using real Chrome browser219220Usage:221npx -y bun x-browser.ts [options] [text]222223Options:224--image <path> Add image (can be repeated, max 4)225--submit Actually post (default: preview only)226--profile <dir> Chrome profile directory227--help Show this help228229Examples:230npx -y bun x-browser.ts "Hello from CLI!"231npx -y bun x-browser.ts "Check this out" --image ./screenshot.png232npx -y bun x-browser.ts "Post it!" --image a.png --image b.png --submit233`);234process.exit(0);235}236237async function main(): Promise<void> {238const args = process.argv.slice(2);239if (args.includes('--help') || args.includes('-h')) printUsage();240241const images: string[] = [];242let submit = false;243let profileDir: string | undefined;244const textParts: string[] = [];245246for (let i = 0; i < args.length; i++) {247const arg = args[i]!;248if (arg === '--image' && args[i + 1]) {249images.push(args[++i]!);250} else if (arg === '--submit') {251submit = true;252} else if (arg === '--profile' && args[i + 1]) {253profileDir = args[++i];254} else if (!arg.startsWith('-')) {255textParts.push(arg);256}257}258259const text = textParts.join(' ').trim() || undefined;260261if (!text && images.length === 0) {262console.error('Error: Provide text or at least one image.');263process.exit(1);264}265266await postToX({ text, images, submit, profileDir });267}268269await main().catch((err) => {270console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);271process.exit(1);272});273