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-video.ts
1import fs from 'node:fs';2import { mkdir } from 'node:fs/promises';3import path from 'node:path';4import process from 'node:process';5import {6CHROME_CANDIDATES_FULL,7CdpConnection,8findExistingChromeDebugPort,9getDefaultProfileDir,10gracefulKillChrome,11launchChrome,12openPageSession,13sleep,14waitForXSessionPersistence,15waitForChromeDebugPort,16} from './x-utils.js';1718const X_COMPOSE_URL = 'https://x.com/compose/post';1920interface XVideoOptions {21text?: string;22videoPath: string;23submit?: boolean;24timeoutMs?: number;25profileDir?: string;26chromePath?: string;27}2829export async function postVideoToX(options: XVideoOptions): Promise<void> {30const { text, videoPath, submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;3132if (!fs.existsSync(videoPath)) throw new Error(`Video not found: ${videoPath}`);3334const absVideoPath = path.resolve(videoPath);35console.log(`[x-video] Video: ${absVideoPath}`);3637await mkdir(profileDir, { recursive: true });3839const existingPort = await findExistingChromeDebugPort(profileDir);40const reusing = existingPort !== null;41let port = existingPort ?? 0;42let chrome: Awaited<ReturnType<typeof launchChrome>>['chrome'] | null = null;43if (!reusing) {44const launched = await launchChrome(X_COMPOSE_URL, profileDir, CHROME_CANDIDATES_FULL, options.chromePath);45port = launched.port;46chrome = launched.chrome;47}4849if (reusing) console.log(`[x-video] Reusing existing Chrome on port ${port}`);50else console.log(`[x-video] Launching Chrome (profile: ${profileDir})`);5152let cdp: CdpConnection | null = null;53let sessionId: string | null = null;54let targetId: string | null = null;55let loggedInDuringRun = false;5657try {58const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });59cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 30_000 });6061const page = await openPageSession({62cdp,63reusing,64url: X_COMPOSE_URL,65matchTarget: (target) => target.type === 'page' && target.url.includes('x.com'),66enablePage: true,67enableRuntime: true,68enableDom: true,69enableNetwork: true,70});71const activeSessionId = page.sessionId;72sessionId = activeSessionId;73targetId = page.targetId;74await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId: activeSessionId });7576console.log('[x-video] Waiting for X editor...');77await sleep(3000);7879const waitForEditor = async (): Promise<boolean> => {80const start = Date.now();81while (Date.now() - start < timeoutMs) {82const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {83expression: `!!document.querySelector('[data-testid="tweetTextarea_0"]')`,84returnByValue: true,85}, { sessionId: activeSessionId });86if (result.result.value) return true;87await sleep(1000);88}89return false;90};9192const editorFound = await waitForEditor();93if (!editorFound) {94console.log('[x-video] Editor not found. Please log in to X in the browser window.');95console.log('[x-video] Waiting for login...');96const loggedIn = await waitForEditor();97if (!loggedIn) throw new Error('Timed out waiting for X editor. Please log in first.');98loggedInDuringRun = true;99}100101// Upload video FIRST (before typing text to avoid text being cleared)102console.log('[x-video] Uploading video...');103104const { root } = await cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {}, { sessionId: activeSessionId });105const { nodeId } = await cdp.send<{ nodeId: number }>('DOM.querySelector', {106nodeId: root.nodeId,107selector: 'input[type="file"][accept*="video"], input[data-testid="fileInput"], input[type="file"]',108}, { sessionId: activeSessionId });109110if (!nodeId || nodeId === 0) {111throw new Error('Could not find file input for video upload.');112}113114await cdp.send('DOM.setFileInputFiles', {115nodeId,116files: [absVideoPath],117}, { sessionId: activeSessionId });118console.log('[x-video] Video file set, uploading in background...');119120// Wait a moment for upload to start, then type text while video processes121await sleep(2000);122123// Type text while video uploads in background124if (text) {125console.log('[x-video] Typing text...');126await cdp.send('Runtime.evaluate', {127expression: `128const editor = document.querySelector('[data-testid="tweetTextarea_0"]');129if (editor) {130editor.focus();131document.execCommand('insertText', false, ${JSON.stringify(text)});132}133`,134}, { sessionId: activeSessionId });135await sleep(500);136}137138// Wait for video to finish processing by checking if tweet button is enabled139console.log('[x-video] Waiting for video processing...');140const waitForVideoReady = async (maxWaitMs = 180_000): Promise<boolean> => {141const start = Date.now();142let dots = 0;143while (Date.now() - start < maxWaitMs) {144const result = await cdp!.send<{ result: { value: { hasMedia: boolean; buttonEnabled: boolean } } }>('Runtime.evaluate', {145expression: `(() => {146const hasMedia = !!document.querySelector('[data-testid="attachments"] video, [data-testid="videoPlayer"], video');147const tweetBtn = document.querySelector('[data-testid="tweetButton"]');148const buttonEnabled = tweetBtn && !tweetBtn.disabled && tweetBtn.getAttribute('aria-disabled') !== 'true';149return { hasMedia, buttonEnabled };150})()`,151returnByValue: true,152}, { sessionId: activeSessionId });153154const { hasMedia, buttonEnabled } = result.result.value;155if (hasMedia && buttonEnabled) {156console.log('');157return true;158}159160process.stdout.write('.');161dots++;162if (dots % 60 === 0) console.log(''); // New line every 60 dots163await sleep(2000);164}165console.log('');166return false;167};168169const videoReady = await waitForVideoReady();170if (videoReady) {171console.log('[x-video] Video ready!');172} else {173console.log('[x-video] Video may still be processing. Please check browser window.');174}175176if (submit) {177console.log('[x-video] Submitting post...');178await cdp.send('Runtime.evaluate', {179expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,180}, { sessionId: activeSessionId });181await sleep(5000);182console.log('[x-video] Post submitted!');183} else {184console.log('[x-video] Post composed (preview mode). Add --submit to post.');185console.log('[x-video] Browser stays open for review.');186}187} finally {188let leaveChromeOpen = !submit;189if (chrome && submit && loggedInDuringRun && cdp && sessionId) {190console.log('[x-video] Waiting for X session cookies to persist...');191const sessionReady = await waitForXSessionPersistence({ cdp, sessionId });192if (!sessionReady) {193console.warn('[x-video] X session cookies not observed yet. Leaving Chrome open so login can finish persisting.');194leaveChromeOpen = true;195}196}197198if (cdp) {199if (reusing && submit && targetId) {200try { await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 }); } catch {}201}202cdp.close();203}204if (chrome && submit) {205if (leaveChromeOpen) {206chrome.unref();207} else {208await gracefulKillChrome(chrome, port);209}210}211}212}213214function printUsage(): never {215console.log(`Post video to X (Twitter) using real Chrome browser216217Usage:218npx -y bun x-video.ts [options] --video <path> [text]219220Options:221--video <path> Video file path (required, supports mp4/mov/webm)222--submit Actually post (default: preview only)223--profile <dir> Chrome profile directory224--help Show this help225226Examples:227npx -y bun x-video.ts --video ./clip.mp4 "Check out this video!"228npx -y bun x-video.ts --video ./demo.mp4 --submit229npx -y bun x-video.ts --video ./video.mp4 "Multi-line text230works too"231232Notes:233- Video is uploaded first, then text is added (to avoid text being cleared)234- Video processing may take 30-60 seconds depending on file size235- Maximum video length on X: 140 seconds (regular) or 60 min (Premium)236- Supported formats: MP4, MOV, WebM237`);238process.exit(0);239}240241async function main(): Promise<void> {242const args = process.argv.slice(2);243if (args.includes('--help') || args.includes('-h')) printUsage();244245let videoPath: string | undefined;246let submit = false;247let profileDir: string | undefined;248const textParts: string[] = [];249250for (let i = 0; i < args.length; i++) {251const arg = args[i]!;252if (arg === '--video' && args[i + 1]) {253videoPath = args[++i]!;254} else if (arg === '--submit') {255submit = true;256} else if (arg === '--profile' && args[i + 1]) {257profileDir = args[++i];258} else if (!arg.startsWith('-')) {259textParts.push(arg);260}261}262263const text = textParts.join(' ').trim() || undefined;264265if (!videoPath) {266console.error('Error: --video <path> is required.');267printUsage();268}269270await postVideoToX({ text, videoPath, submit, profileDir });271}272273await main().catch((err) => {274console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);275process.exit(1);276});277