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-quote.ts
1import { mkdir } from 'node:fs/promises';2import process from 'node:process';3import {4CHROME_CANDIDATES_FULL,5CdpConnection,6findExistingChromeDebugPort,7getDefaultProfileDir,8gracefulKillChrome,9launchChrome,10openPageSession,11sleep,12waitForXSessionPersistence,13waitForChromeDebugPort,14} from './x-utils.js';1516function extractTweetUrl(urlOrId: string): string | null {17// If it's already a full URL, normalize it18if (urlOrId.match(/(?:x\.com|twitter\.com)\/\w+\/status\/\d+/)) {19return urlOrId.replace(/twitter\.com/, 'x.com').split('?')[0];20}21return null;22}2324interface QuoteOptions {25tweetUrl: string;26comment?: string;27submit?: boolean;28timeoutMs?: number;29profileDir?: string;30chromePath?: string;31}3233export async function quotePost(options: QuoteOptions): Promise<void> {34const { tweetUrl, comment, submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;3536await mkdir(profileDir, { recursive: true });3738const existingPort = await findExistingChromeDebugPort(profileDir);39const reusing = existingPort !== null;40let port = existingPort ?? 0;41console.log(`[x-quote] Opening tweet: ${tweetUrl}`);42let chrome: Awaited<ReturnType<typeof launchChrome>>['chrome'] | null = null;43if (!reusing) {44const launched = await launchChrome(tweetUrl, profileDir, CHROME_CANDIDATES_FULL, options.chromePath);45port = launched.port;46chrome = launched.chrome;47}4849if (reusing) console.log(`[x-quote] Reusing existing Chrome on port ${port}`);50else console.log(`[x-quote] 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: 15_000 });6061const page = await openPageSession({62cdp,63reusing,64url: tweetUrl,65matchTarget: (target) => target.type === 'page' && target.url.includes('x.com'),66enablePage: true,67enableRuntime: true,68enableNetwork: true,69});70const activeSessionId = page.sessionId;71sessionId = activeSessionId;72targetId = page.targetId;7374console.log('[x-quote] Waiting for tweet to load...');75await sleep(3000);7677// Wait for retweet button to appear (indicates tweet loaded and user logged in)78const waitForRetweetButton = async (): Promise<boolean> => {79const start = Date.now();80while (Date.now() - start < timeoutMs) {81const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {82expression: `!!document.querySelector('[data-testid="retweet"]')`,83returnByValue: true,84}, { sessionId: activeSessionId });85if (result.result.value) return true;86await sleep(1000);87}88return false;89};9091const retweetFound = await waitForRetweetButton();92if (!retweetFound) {93console.log('[x-quote] Tweet not found or not logged in. Please log in to X in the browser window.');94console.log('[x-quote] Waiting for login...');95const loggedIn = await waitForRetweetButton();96if (!loggedIn) throw new Error('Timed out waiting for tweet. Please log in first or check the tweet URL.');97loggedInDuringRun = true;98}99100// Click the retweet button101console.log('[x-quote] Clicking retweet button...');102await cdp.send('Runtime.evaluate', {103expression: `document.querySelector('[data-testid="retweet"]')?.click()`,104}, { sessionId: activeSessionId });105await sleep(1000);106107// Wait for and click the "Quote" option in the menu108console.log('[x-quote] Selecting quote option...');109const waitForQuoteOption = async (): Promise<boolean> => {110const start = Date.now();111while (Date.now() - start < 10_000) {112const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {113expression: `!!document.querySelector('[data-testid="Dropdown"] [role="menuitem"]:nth-child(2)')`,114returnByValue: true,115}, { sessionId: activeSessionId });116if (result.result.value) return true;117await sleep(200);118}119return false;120};121122const quoteOptionFound = await waitForQuoteOption();123if (!quoteOptionFound) {124throw new Error('Quote option not found. The menu may not have opened.');125}126127// Click the quote option (second menu item)128await cdp.send('Runtime.evaluate', {129expression: `document.querySelector('[data-testid="Dropdown"] [role="menuitem"]:nth-child(2)')?.click()`,130}, { sessionId: activeSessionId });131await sleep(2000);132133// Wait for the quote compose dialog134console.log('[x-quote] Waiting for quote compose dialog...');135const waitForQuoteDialog = async (): Promise<boolean> => {136const start = Date.now();137while (Date.now() - start < 10_000) {138const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {139expression: `!!document.querySelector('[data-testid="tweetTextarea_0"]')`,140returnByValue: true,141}, { sessionId: activeSessionId });142if (result.result.value) return true;143await sleep(200);144}145return false;146};147148const dialogFound = await waitForQuoteDialog();149if (!dialogFound) {150throw new Error('Quote compose dialog not found.');151}152153// Type the comment if provided154if (comment) {155console.log('[x-quote] Typing comment...');156// Use CDP Input.insertText for more reliable text insertion157await cdp.send('Runtime.evaluate', {158expression: `document.querySelector('[data-testid="tweetTextarea_0"]')?.focus()`,159}, { sessionId: activeSessionId });160await sleep(200);161162await cdp.send('Input.insertText', {163text: comment,164}, { sessionId: activeSessionId });165await sleep(500);166}167168if (submit) {169console.log('[x-quote] Submitting quote post...');170await cdp.send('Runtime.evaluate', {171expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,172}, { sessionId: activeSessionId });173await sleep(2000);174console.log('[x-quote] Quote post submitted!');175} else {176console.log('[x-quote] Quote composed (preview mode). Add --submit to post.');177console.log('[x-quote] Browser will stay open for 30 seconds for preview...');178await sleep(30_000);179}180} finally {181let leaveChromeOpen = false;182if (chrome && loggedInDuringRun && cdp && sessionId) {183console.log('[x-quote] Waiting for X session cookies to persist...');184const sessionReady = await waitForXSessionPersistence({ cdp, sessionId });185if (!sessionReady) {186console.warn('[x-quote] X session cookies not observed yet. Leaving Chrome open so login can finish persisting.');187leaveChromeOpen = true;188}189}190191if (cdp) {192if (reusing && targetId) {193try { await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 }); } catch {}194}195cdp.close();196}197if (chrome) {198if (leaveChromeOpen) {199chrome.unref();200} else {201await gracefulKillChrome(chrome, port);202}203}204}205}206207function printUsage(): never {208console.log(`Quote a tweet on X (Twitter) using real Chrome browser209210Usage:211npx -y bun x-quote.ts <tweet-url> [options] [comment]212213Options:214--submit Actually post (default: preview only)215--profile <dir> Chrome profile directory216--help Show this help217218Examples:219npx -y bun x-quote.ts https://x.com/user/status/123456789 "Great insight!"220npx -y bun x-quote.ts https://x.com/user/status/123456789 "I agree!" --submit221`);222process.exit(0);223}224225async function main(): Promise<void> {226const args = process.argv.slice(2);227if (args.includes('--help') || args.includes('-h')) printUsage();228229let tweetUrl: string | undefined;230let submit = false;231let profileDir: string | undefined;232const commentParts: string[] = [];233234for (let i = 0; i < args.length; i++) {235const arg = args[i]!;236if (arg === '--submit') {237submit = true;238} else if (arg === '--profile' && args[i + 1]) {239profileDir = args[++i];240} else if (!arg.startsWith('-')) {241// First non-option argument is the tweet URL242if (!tweetUrl && arg.match(/(?:x\.com|twitter\.com)\/\w+\/status\/\d+/)) {243tweetUrl = extractTweetUrl(arg) ?? undefined;244} else {245commentParts.push(arg);246}247}248}249250if (!tweetUrl) {251console.error('Error: Please provide a tweet URL.');252console.error('Example: npx -y bun x-quote.ts https://x.com/user/status/123456789 "Your comment"');253process.exit(1);254}255256const comment = commentParts.join(' ').trim() || undefined;257258await quotePost({ tweetUrl, comment, submit, profileDir });259}260261await main().catch((err) => {262console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);263process.exit(1);264});265