From cdc5a9c41ca69e716ffd41fa8a98086f2ff48367 Mon Sep 17 00:00:00 2001 From: fkysly Date: Wed, 21 Jan 2026 10:59:00 +0800 Subject: [PATCH] feat: add video posting support - Add x-video.ts script for posting videos to X - Video is uploaded first, then text is added (fixes text being cleared issue) - Uses DOM.setFileInputFiles for direct video upload via CDP - Supports MP4, MOV, WebM formats - Waits for video processing before allowing submission - Update SKILL.md with video posting documentation --- skills/baoyu-post-to-x/SKILL.md | 33 +- skills/baoyu-post-to-x/scripts/x-video.ts | 407 ++++++++++++++++++++++ 2 files changed, 438 insertions(+), 2 deletions(-) create mode 100644 skills/baoyu-post-to-x/scripts/x-video.ts diff --git a/skills/baoyu-post-to-x/SKILL.md b/skills/baoyu-post-to-x/SKILL.md index dd3cb7b..cfcb1f4 100644 --- a/skills/baoyu-post-to-x/SKILL.md +++ b/skills/baoyu-post-to-x/SKILL.md @@ -1,11 +1,11 @@ --- name: baoyu-post-to-x -description: Post content and articles to X (Twitter). Supports regular posts with images and X Articles (long-form Markdown). Uses real Chrome with CDP to bypass anti-automation. +description: Post content and articles to X (Twitter). Supports regular posts with images/videos and X Articles (long-form Markdown). Uses real Chrome with CDP to bypass anti-automation. --- # Post to X (Twitter) -Post content, images, and long-form articles to X using real Chrome browser (bypasses anti-bot detection). +Post content, images, videos, and long-form articles to X using real Chrome browser (bypasses anti-bot detection). ## Script Directory @@ -20,6 +20,7 @@ Post content, images, and long-form articles to X using real Chrome browser (byp | Script | Purpose | |--------|---------| | `scripts/x-browser.ts` | Regular posts (text + images) | +| `scripts/x-video.ts` | Video posts (text + video) | | `scripts/x-article.ts` | Long-form article publishing (Markdown) | | `scripts/md-to-html.ts` | Markdown → HTML conversion | | `scripts/copy-to-clipboard.ts` | Copy content to clipboard | @@ -62,6 +63,34 @@ npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png --subm --- +## Video Posts + +Text + video file (MP4, MOV, WebM). + +```bash +# Preview mode (doesn't post) +npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Check out this video!" --video ./clip.mp4 + +# Actually post +npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Amazing content" --video ./demo.mp4 --submit +``` + +**Parameters**: +| Parameter | Description | +|-----------|-------------| +| `` | Post content (positional argument) | +| `--video ` | Video file path (required) | +| `--submit` | Actually post (default: preview only) | +| `--profile ` | Custom Chrome profile directory | + +**Video Limits**: +- Regular accounts: 140 seconds max +- X Premium: up to 60 minutes +- Supported formats: MP4, MOV, WebM +- Processing time: 30-60 seconds depending on file size + +--- + ## X Articles Long-form Markdown articles (requires X Premium). diff --git a/skills/baoyu-post-to-x/scripts/x-video.ts b/skills/baoyu-post-to-x/scripts/x-video.ts new file mode 100644 index 0000000..3f33fe6 --- /dev/null +++ b/skills/baoyu-post-to-x/scripts/x-video.ts @@ -0,0 +1,407 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; + +const X_COMPOSE_URL = 'https://x.com/compose/post'; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Unable to allocate a free TCP port.'))); + return; + } + const port = address.port; + server.close((err) => { + if (err) reject(err); + else resolve(port); + }); + }); + }); +} + +function findChromeExecutable(): string | undefined { + const override = process.env.X_BROWSER_CHROME_PATH?.trim(); + if (override && fs.existsSync(override)) return override; + + const candidates: string[] = []; + switch (process.platform) { + case 'darwin': + candidates.push( + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + ); + break; + case 'win32': + candidates.push( + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', + 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', + ); + break; + default: + candidates.push( + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/snap/bin/chromium', + '/usr/bin/microsoft-edge', + ); + break; + } + + for (const p of candidates) { + if (fs.existsSync(p)) return p; + } + return undefined; +} + +function getDefaultProfileDir(): string { + const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); + return path.join(base, 'x-browser-profile'); +} + +async function fetchJson(url: string): Promise { + const res = await fetch(url, { redirect: 'follow' }); + if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`); + return (await res.json()) as T; +} + +async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise { + const start = Date.now(); + let lastError: unknown = null; + + while (Date.now() - start < timeoutMs) { + try { + const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`); + if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl; + lastError = new Error('Missing webSocketDebuggerUrl'); + } catch (error) { + lastError = error; + } + await sleep(200); + } + + throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`); +} + +class CdpConnection { + private ws: WebSocket; + private nextId = 0; + private pending = new Map void; reject: (e: Error) => void; timer: ReturnType | null }>(); + + private constructor(ws: WebSocket) { + this.ws = ws; + this.ws.addEventListener('message', (event) => { + try { + const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer); + const msg = JSON.parse(data) as { id?: number; result?: unknown; error?: { message?: string } }; + + if (msg.id) { + const pending = this.pending.get(msg.id); + if (pending) { + this.pending.delete(msg.id); + if (pending.timer) clearTimeout(pending.timer); + if (msg.error?.message) pending.reject(new Error(msg.error.message)); + else pending.resolve(msg.result); + } + } + } catch {} + }); + + this.ws.addEventListener('close', () => { + for (const [id, pending] of this.pending.entries()) { + this.pending.delete(id); + if (pending.timer) clearTimeout(pending.timer); + pending.reject(new Error('CDP connection closed.')); + } + }); + } + + static async connect(url: string, timeoutMs: number): Promise { + const ws = new WebSocket(url); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('CDP connection timeout.')), timeoutMs); + ws.addEventListener('open', () => { clearTimeout(timer); resolve(); }); + ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('CDP connection failed.')); }); + }); + return new CdpConnection(ws); + } + + async send(method: string, params?: Record, options?: { sessionId?: string; timeoutMs?: number }): Promise { + const id = ++this.nextId; + const message: Record = { id, method }; + if (params) message.params = params; + if (options?.sessionId) message.sessionId = options.sessionId; + + const timeoutMs = options?.timeoutMs ?? 30_000; + + const result = await new Promise((resolve, reject) => { + const timer = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) : null; + this.pending.set(id, { resolve, reject, timer }); + this.ws.send(JSON.stringify(message)); + }); + + return result as T; + } + + close(): void { + try { this.ws.close(); } catch {} + } +} + +interface XVideoOptions { + text?: string; + videoPath: string; + submit?: boolean; + timeoutMs?: number; + profileDir?: string; + chromePath?: string; +} + +export async function postVideoToX(options: XVideoOptions): Promise { + const { text, videoPath, submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options; + + const chromePath = options.chromePath ?? findChromeExecutable(); + if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.'); + + if (!fs.existsSync(videoPath)) throw new Error(`Video not found: ${videoPath}`); + + const absVideoPath = path.resolve(videoPath); + console.log(`[x-video] Video: ${absVideoPath}`); + + await mkdir(profileDir, { recursive: true }); + + const port = await getFreePort(); + console.log(`[x-video] Launching Chrome (profile: ${profileDir})`); + + const chrome = spawn(chromePath, [ + `--remote-debugging-port=${port}`, + `--user-data-dir=${profileDir}`, + '--no-first-run', + '--no-default-browser-check', + '--disable-blink-features=AutomationControlled', + '--start-maximized', + X_COMPOSE_URL, + ], { stdio: 'ignore' }); + + let cdp: CdpConnection | null = null; + + try { + const wsUrl = await waitForChromeDebugPort(port, 30_000); + cdp = await CdpConnection.connect(wsUrl, 30_000); + + const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets'); + let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('x.com')); + + if (!pageTarget) { + const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: X_COMPOSE_URL }); + pageTarget = { targetId, url: X_COMPOSE_URL, type: 'page' }; + } + + const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true }); + + await cdp.send('Page.enable', {}, { sessionId }); + await cdp.send('Runtime.enable', {}, { sessionId }); + await cdp.send('DOM.enable', {}, { sessionId }); + await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId }); + + console.log('[x-video] Waiting for X editor...'); + await sleep(3000); + + const waitForEditor = async (): Promise => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', { + expression: `!!document.querySelector('[data-testid="tweetTextarea_0"]')`, + returnByValue: true, + }, { sessionId }); + if (result.result.value) return true; + await sleep(1000); + } + return false; + }; + + const editorFound = await waitForEditor(); + if (!editorFound) { + console.log('[x-video] Editor not found. Please log in to X in the browser window.'); + console.log('[x-video] Waiting for login...'); + const loggedIn = await waitForEditor(); + if (!loggedIn) throw new Error('Timed out waiting for X editor. Please log in first.'); + } + + // Upload video FIRST (before typing text to avoid text being cleared) + console.log('[x-video] Uploading video...'); + + 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[type="file"][accept*="video"], input[data-testid="fileInput"], input[type="file"]', + }, { sessionId }); + + if (!nodeId || nodeId === 0) { + throw new Error('Could not find file input for video upload.'); + } + + await cdp.send('DOM.setFileInputFiles', { + nodeId, + files: [absVideoPath], + }, { sessionId }); + console.log('[x-video] Video file set, waiting for processing...'); + + // Wait for video to process + const waitForVideoReady = async (maxWaitMs = 180_000): Promise => { + const start = Date.now(); + let dots = 0; + while (Date.now() - start < maxWaitMs) { + const result = await cdp!.send<{ result: { value: { hasMedia: boolean; isProcessing: boolean } } }>('Runtime.evaluate', { + expression: `(() => { + const hasMedia = !!document.querySelector('[data-testid="attachments"] video, [data-testid="videoPlayer"], video'); + const isProcessing = !!document.querySelector('[role="progressbar"], [data-testid="progressBar"]'); + return { hasMedia, isProcessing }; + })()`, + returnByValue: true, + }, { sessionId }); + + const { hasMedia, isProcessing } = result.result.value; + if (hasMedia && !isProcessing) { + console.log(''); + return true; + } + + process.stdout.write('.'); + dots++; + if (dots % 60 === 0) console.log(''); // New line every 60 dots + await sleep(2000); + } + console.log(''); + return false; + }; + + const videoReady = await waitForVideoReady(); + if (videoReady) { + console.log('[x-video] Video ready!'); + } else { + console.log('[x-video] Video may still be processing. Please check browser window.'); + } + + // Type text AFTER video is uploaded + if (text) { + console.log('[x-video] Typing text...'); + await cdp.send('Runtime.evaluate', { + expression: ` + const editor = document.querySelector('[data-testid="tweetTextarea_0"]'); + if (editor) { + editor.focus(); + document.execCommand('insertText', false, ${JSON.stringify(text)}); + } + `, + }, { sessionId }); + await sleep(500); + } + + if (submit) { + console.log('[x-video] Submitting post...'); + await cdp.send('Runtime.evaluate', { + expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`, + }, { sessionId }); + await sleep(5000); + console.log('[x-video] Post submitted!'); + } else { + console.log('[x-video] Post composed (preview mode). Add --submit to post.'); + console.log('[x-video] Browser stays open for review.'); + } + } finally { + if (cdp) { + cdp.close(); + } + // Don't kill Chrome in preview mode, let user review + if (submit) { + setTimeout(() => { + if (!chrome.killed) try { chrome.kill('SIGKILL'); } catch {} + }, 2_000).unref?.(); + try { chrome.kill('SIGTERM'); } catch {} + } + } +} + +function printUsage(): never { + console.log(`Post video to X (Twitter) using real Chrome browser + +Usage: + npx -y bun x-video.ts [options] --video [text] + +Options: + --video Video file path (required, supports mp4/mov/webm) + --submit Actually post (default: preview only) + --profile Chrome profile directory + --help Show this help + +Examples: + npx -y bun x-video.ts --video ./clip.mp4 "Check out this video!" + npx -y bun x-video.ts --video ./demo.mp4 --submit + npx -y bun x-video.ts --video ./video.mp4 "Multi-line text +works too" + +Notes: + - Video is uploaded first, then text is added (to avoid text being cleared) + - Video processing may take 30-60 seconds depending on file size + - Maximum video length on X: 140 seconds (regular) or 60 min (Premium) + - Supported formats: MP4, MOV, WebM +`); + process.exit(0); +} + +async function main(): Promise { + const args = process.argv.slice(2); + if (args.includes('--help') || args.includes('-h')) printUsage(); + + let videoPath: string | undefined; + let submit = false; + let profileDir: string | undefined; + const textParts: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + if (arg === '--video' && args[i + 1]) { + videoPath = args[++i]!; + } else if (arg === '--submit') { + submit = true; + } else if (arg === '--profile' && args[i + 1]) { + profileDir = args[++i]; + } else if (!arg.startsWith('-')) { + textParts.push(arg); + } + } + + const text = textParts.join(' ').trim() || undefined; + + if (!videoPath) { + console.error('Error: --video is required.'); + printUsage(); + } + + await postVideoToX({ text, videoPath, submit, profileDir }); +} + +await main().catch((err) => { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +});