
Tuzi Post To X
- 150 installs
- 37 repo stars
- Updated April 24, 2026
- tuziapi/tuzi-skills
Use tuzi-post-to-x for development tasks
About
tuzi-post-to-x: A skill skill for development. This skill provides functionality for development workflows.
- tuzi-post-to-x
Tuzi Post To X by the numbers
- 150 all-time installs (skills.sh)
- Ranked #2,522 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tuziapi/tuzi-skills --skill tuzi-post-to-xAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 150 |
|---|---|
| repo stars | ★ 37 |
| Last updated | April 24, 2026 |
| Repository | tuziapi/tuzi-skills ↗ |
What it does
Use tuzi-post-to-x for development tasks
Files
Post to X (Twitter)
Posts text, images, videos, and long-form articles to X via real Chrome browser (bypasses anti-bot detection).
Script Directory
Important: All scripts are located in the scripts/ subdirectory of this skill.
Agent Execution Instructions: 1. Determine this SKILL.md file's directory path as SKILL_DIR 2. Script path = ${SKILL_DIR}/scripts/<script-name>.ts 3. Replace all ${SKILL_DIR} in this document with the actual path
Script Reference:
| Script | Purpose |
|---|---|
scripts/x-browser.ts | Regular posts (text + images) |
scripts/x-video.ts | Video posts (text + video) |
scripts/x-quote.ts | Quote tweet with comment |
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 |
scripts/paste-from-clipboard.ts | Send real paste keystroke |
scripts/check-paste-permissions.ts | Verify environment & permissions |
Preferences (EXTEND.md)
Use Bash to check EXTEND.md existence (priority order):
# Check project-level first
test -f .tuzi-skills/tuzi-post-to-x/EXTEND.md && echo "project"
# Then user-level (cross-platform: $HOME works on macOS/Linux/WSL)
test -f "$HOME/.tuzi-skills/tuzi-post-to-x/EXTEND.md" && echo "user"┌──────────────────────────────────────────────────┬───────────────────┐ │ Path │ Location │ ├──────────────────────────────────────────────────┼───────────────────┤ │ .tuzi-skills/tuzi-post-to-x/EXTEND.md │ Project directory │ ├──────────────────────────────────────────────────┼───────────────────┤ │ $HOME/.tuzi-skills/tuzi-post-to-x/EXTEND.md │ User home │ └──────────────────────────────────────────────────┴───────────────────┘
┌───────────┬───────────────────────────────────────────────────────────────────────────┐ │ Result │ Action │ ├───────────┼───────────────────────────────────────────────────────────────────────────┤ │ Found │ Read, parse, apply settings │ ├───────────┼───────────────────────────────────────────────────────────────────────────┤ │ Not found │ Use defaults │ └───────────┴───────────────────────────────────────────────────────────────────────────┘
EXTEND.md Supports: Default Chrome profile
Prerequisites
- Google Chrome or Chromium
bunruntime- First run: log in to X manually (session saved)
Pre-flight Check (Optional)
Before first use, suggest running the environment check. User can skip if they prefer.
npx -y bun ${SKILL_DIR}/scripts/check-paste-permissions.tsChecks: Chrome, profile isolation, Bun, Accessibility, clipboard, paste keystroke, Chrome conflicts.
If any check fails, provide fix guidance per item:
| Check | Fix |
|---|---|
| Chrome | Install Chrome or set X_BROWSER_CHROME_PATH env var |
| Profile dir | Ensure ~/.local/share/x-browser-profile is writable |
| Bun runtime | `curl -fsSL https://bun.sh/install \ |
| Accessibility (macOS) | System Settings → Privacy & Security → Accessibility → enable terminal app |
| Clipboard copy | Ensure Swift/AppKit available (macOS Xcode CLI tools: xcode-select --install) |
| Paste keystroke (macOS) | Same as Accessibility fix above |
| Paste keystroke (Linux) | Install xdotool (X11) or ydotool (Wayland) |
References
- Regular Posts: See
references/regular-posts.mdfor manual workflow, troubleshooting, and technical details - X Articles: See
references/articles.mdfor long-form article publishing guide
---
Regular Posts
Text + up to 4 images.
npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.pngParameters:
| Parameter | Description |
|---|---|
<text> | Post content (positional) |
--image <path> | Image file (repeatable, max 4) |
--profile <dir> | Custom Chrome profile |
Note: Script opens browser with content filled in. User reviews and publishes manually.
---
Video Posts
Text + video file.
npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Check this out!" --video ./clip.mp4Parameters:
| Parameter | Description |
|---|---|
<text> | Post content (positional) |
--video <path> | Video file (MP4, MOV, WebM) |
--profile <dir> | Custom Chrome profile |
Note: Script opens browser with content filled in. User reviews and publishes manually.
Limits: Regular 140s max, Premium 60min. Processing: 30-60s.
---
Quote Tweets
Quote an existing tweet with comment.
npx -y bun ${SKILL_DIR}/scripts/x-quote.ts https://x.com/user/status/123 "Great insight!"Parameters:
| Parameter | Description |
|---|---|
<tweet-url> | URL to quote (positional) |
<comment> | Comment text (positional, optional) |
--profile <dir> | Custom Chrome profile |
Note: Script opens browser with content filled in. User reviews and publishes manually.
---
X Articles
Long-form Markdown articles (requires X Premium).
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --cover ./cover.jpgParameters:
| Parameter | Description |
|---|---|
<markdown> | Markdown file (positional) |
--cover <path> | Cover image |
--title <text> | Override title |
Frontmatter: title, cover_image supported in YAML front matter.
Note: Script opens browser with article filled in. User reviews and publishes manually.
Post-Composition Check: The script automatically verifies after all images are inserted:
- Remaining
XIMGPH_placeholders in editor content - Expected vs actual image count
If the check fails (warnings in output), alert the user with the specific issues before they publish.
---
Troubleshooting
Chrome debug port not ready
If a script fails with Chrome debug port not ready or Unable to connect, kill existing Chrome CDP instances first, then retry:
pkill -f "Chrome.*remote-debugging-port" 2>/dev/null; pkill -f "Chromium.*remote-debugging-port" 2>/dev/null; sleep 2Important: This should be done automatically — when encountering this error, kill Chrome CDP instances and retry the command without asking the user.
Notes
- First run: manual login required (session persists)
- All scripts only fill content into the browser, user must review and publish manually
- Cross-platform: macOS, Linux, Windows
Extension Support
Custom configurations via EXTEND.md. See Preferences section for paths and supported options.
X Articles - Detailed Guide
Publish Markdown articles to X Articles editor with rich text formatting and images.
Prerequisites
- X Premium subscription (required for Articles)
- Google Chrome installed
buninstalled
Usage
# Publish markdown article (preview mode)
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md
# With custom cover image
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --cover ./cover.jpg
# Actually publish
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --submitMarkdown Format
---
title: My Article Title
cover_image: /path/to/cover.jpg
---
# Title (becomes article title)
Regular paragraph text with **bold** and *italic*.
## Section Header
More content here.

- List item 1
- List item 2
1. Numbered item
2. Another item
> Blockquote text
[Link text](https://example.com)
\`\`\`
Code blocks become blockquotes (X doesn't support code)
\`\`\`Frontmatter Fields
| Field | Description |
|---|---|
title | Article title (or uses first H1) |
cover_image | Cover image path or URL |
cover | Alias for cover_image |
image | Alias for cover_image |
Image Handling
1. Cover Image: First image or cover_image from frontmatter 2. Remote Images: Automatically downloaded to temp directory 3. Placeholders: Images in content use XIMGPH_N format 4. Insertion: Placeholders are found, selected, and replaced with actual images
Markdown to HTML Script
Convert markdown and inspect structure:
# Get JSON with all metadata
npx -y bun ${SKILL_DIR}/scripts/md-to-html.ts article.md
# Output HTML only
npx -y bun ${SKILL_DIR}/scripts/md-to-html.ts article.md --html-only
# Save HTML to file
npx -y bun ${SKILL_DIR}/scripts/md-to-html.ts article.md --save-html /tmp/article.htmlJSON output:
{
"title": "Article Title",
"coverImage": "/path/to/cover.jpg",
"contentImages": [
{
"placeholder": "XIMGPH_1",
"localPath": "/tmp/x-article-images/img.png",
"blockIndex": 5
}
],
"html": "<p>Content...</p>",
"totalBlocks": 20
}Supported Formatting
| Markdown | HTML Output |
|---|---|
# H1 | Title only (not in body) |
## H2 - ###### H6 | <h2> |
**bold** | <strong> |
*italic* | <em> |
[text](url) | <a href> |
> quote | <blockquote> |
` code ` | <code> |
``` ` ``` | <blockquote> (X limitation) |
- item | <ul><li> |
1. item | <ol><li> |
 | Image placeholder |
Workflow
1. Parse Markdown: Extract title, cover, content images, generate HTML 2. Launch Chrome: Real browser with CDP, persistent login 3. Navigate: Open x.com/compose/articles 4. Create Article: Click create button if on list page 5. Upload Cover: Use file input for cover image 6. Fill Title: Type title into title field 7. Paste Content: Copy HTML to clipboard, paste into editor 8. Insert Images: For each placeholder (reverse order):
- Find placeholder text in editor
- Select the placeholder
- Copy image to clipboard
- Paste to replace selection
9. Post-Composition Check (automatic):
- Scan editor for remaining
XIMGPH_placeholders - Compare expected vs actual image count
- Warn if issues found
10. Review: Browser stays open for 60s preview 11. Publish: Only with --submit flag
Example Session
User: /post-to-x article ./blog/my-post.md --cover ./thumbnail.png
Claude:
1. Parses markdown: title="My Post", 3 content images
2. Launches Chrome with CDP
3. Navigates to x.com/compose/articles
4. Clicks create button
5. Uploads thumbnail.png as cover
6. Fills title "My Post"
7. Pastes HTML content
8. Inserts 3 images at placeholder positions
9. Reports: "Article composed. Review and use --submit to publish."Troubleshooting
- No create button: Ensure X Premium subscription is active
- Cover upload fails: Check file path and format (PNG, JPEG)
- Images not inserting: Verify placeholders exist in pasted content
- Content not pasting: Check HTML clipboard:
npx -y bun ${SKILL_DIR}/scripts/copy-to-clipboard.ts html --file /tmp/test.html
How It Works
1. md-to-html.ts converts Markdown to HTML:
- Extracts frontmatter (title, cover)
- Converts markdown to HTML
- Replaces images with unique placeholders
- Downloads remote images locally
- Returns structured JSON
2. x-article.ts publishes via CDP:
- Launches real Chrome (bypasses detection)
- Uses persistent profile (saved login)
- Navigates and fills editor via DOM manipulation
- Pastes HTML from system clipboard
- Finds/selects/replaces each image placeholder
Regular Posts - Detailed Guide
Detailed documentation for posting text and images to X.
Manual Workflow
If you prefer step-by-step control:
Step 1: Copy Image to Clipboard
npx -y bun ${SKILL_DIR}/scripts/copy-to-clipboard.ts image /path/to/image.pngStep 2: Paste from Clipboard
# Simple paste to frontmost app
npx -y bun ${SKILL_DIR}/scripts/paste-from-clipboard.ts
# Paste to Chrome with retries
npx -y bun ${SKILL_DIR}/scripts/paste-from-clipboard.ts --app "Google Chrome" --retries 5
# Quick paste with shorter delay
npx -y bun ${SKILL_DIR}/scripts/paste-from-clipboard.ts --delay 200Step 3: Use Playwright MCP (if Chrome session available)
# Navigate
mcp__playwright__browser_navigate url="https://x.com/compose/post"
# Get element refs
mcp__playwright__browser_snapshot
# Type text
mcp__playwright__browser_click element="editor" ref="<ref>"
mcp__playwright__browser_type element="editor" ref="<ref>" text="Your content"
# Paste image (after copying to clipboard)
mcp__playwright__browser_press_key key="Meta+v" # macOS
# or
mcp__playwright__browser_press_key key="Control+v" # Windows/Linux
# Screenshot to verify
mcp__playwright__browser_take_screenshot filename="preview.png"Image Support
- Formats: PNG, JPEG, GIF, WebP
- Max 4 images per post
- Images copied to system clipboard, then pasted via keyboard shortcut
Example Session
User: /post-to-x "Hello from Claude!" --image ./screenshot.png
Claude:
1. Runs: npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello from Claude!" --image ./screenshot.png
2. Chrome opens with X compose page
3. Text is typed into editor
4. Image is copied to clipboard and pasted
5. Browser stays open 30s for preview
6. Reports: "Post composed. Use --submit to post."Troubleshooting
- Chrome not found: Set
X_BROWSER_CHROME_PATHenvironment variable - Not logged in: First run opens Chrome - log in manually, cookies are saved
- Image paste fails:
- Verify clipboard script:
npx -y bun ${SKILL_DIR}/scripts/copy-to-clipboard.ts image <path> - On macOS, grant "Accessibility" permission to Terminal/iTerm in System Settings > Privacy & Security > Accessibility
- Keep Chrome window visible and in front during paste operations
- osascript permission denied: Grant Terminal accessibility permissions in System Preferences
- Rate limited: Wait a few minutes before retrying
How It Works
The x-browser.ts script uses Chrome DevTools Protocol (CDP) to: 1. Launch real Chrome (not Playwright) with --disable-blink-features=AutomationControlled 2. Use persistent profile directory for saved login sessions 3. Interact with X via CDP commands (Runtime.evaluate, Input.dispatchKeyEvent) 4. Paste images using osascript (macOS): Sends real Cmd+V keystroke to Chrome, bypassing CDP's synthetic events that X can detect
This approach bypasses X's anti-automation detection that blocks Playwright/Puppeteer.
Image Paste Mechanism (macOS)
CDP's Input.dispatchKeyEvent sends "synthetic" keyboard events that websites can detect. X ignores synthetic paste events for security. The solution:
1. Copy image to system clipboard via Swift/AppKit (copy-to-clipboard.ts) 2. Bring Chrome to front via osascript 3. Send real Cmd+V keystroke via osascript and System Events 4. Wait for upload to complete
This requires Terminal to have "Accessibility" permission in System Settings.
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { findChromeExecutable, CHROME_CANDIDATES_FULL, getDefaultProfileDir } from './x-utils.js';
interface CheckResult {
name: string;
ok: boolean;
detail: string;
}
const results: CheckResult[] = [];
function log(label: string, ok: boolean, detail: string): void {
results.push({ name: label, ok, detail });
const icon = ok ? '✅' : '❌';
console.log(`${icon} ${label}: ${detail}`);
}
function warn(label: string, detail: string): void {
results.push({ name: label, ok: true, detail });
console.log(`⚠️ ${label}: ${detail}`);
}
async function checkChrome(): Promise<void> {
const chromePath = findChromeExecutable(CHROME_CANDIDATES_FULL);
if (chromePath) {
log('Chrome', true, chromePath);
} else {
log('Chrome', false, 'Not found. Set X_BROWSER_CHROME_PATH env var or install Chrome.');
}
}
async function checkProfileIsolation(): Promise<void> {
const profileDir = getDefaultProfileDir();
const userChromeDir = process.platform === 'darwin'
? path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome')
: process.platform === 'win32'
? path.join(os.homedir(), 'AppData', 'Local', 'Google', 'Chrome', 'User Data')
: path.join(os.homedir(), '.config', 'google-chrome');
const isIsolated = !profileDir.startsWith(userChromeDir);
log('Profile isolation', isIsolated, `Skill profile: ${profileDir}`);
if (isIsolated) {
const exists = fs.existsSync(profileDir);
if (exists) {
log('Profile dir', true, 'Exists and accessible');
} else {
try {
fs.mkdirSync(profileDir, { recursive: true });
log('Profile dir', true, 'Created successfully');
} catch (e) {
log('Profile dir', false, `Cannot create: ${e instanceof Error ? e.message : String(e)}`);
}
}
}
}
async function checkAccessibility(): Promise<void> {
if (process.platform !== 'darwin') {
log('Accessibility', true, `Skipped (not macOS, platform: ${process.platform})`);
return;
}
const result = spawnSync('osascript', ['-e', `
tell application "System Events"
set frontApp to name of first application process whose frontmost is true
return frontApp
end tell
`], { stdio: 'pipe', timeout: 10_000 });
if (result.status === 0) {
const app = result.stdout?.toString().trim();
log('Accessibility (System Events)', true, `Frontmost app: ${app}`);
} else {
const stderr = result.stderr?.toString().trim() || '';
if (stderr.includes('not allowed assistive access') || stderr.includes('1002')) {
log('Accessibility (System Events)', false,
'Denied. Grant access: System Settings → Privacy & Security → Accessibility → enable your terminal app');
} else {
log('Accessibility (System Events)', false, `Failed: ${stderr}`);
}
}
}
async function checkClipboardCopy(): Promise<void> {
if (process.platform !== 'darwin') {
log('Clipboard copy (image)', true, `Skipped (not macOS)`);
return;
}
const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'x-check-'));
try {
const testPng = path.join(tmpDir, 'test.png');
const swiftSrc = `import AppKit
import Foundation
let size = NSSize(width: 2, height: 2)
let image = NSImage(size: size)
image.lockFocus()
NSColor.red.set()
NSBezierPath.fill(NSRect(origin: .zero, size: size))
image.unlockFocus()
guard let tiff = image.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff),
let png = rep.representation(using: .png, properties: [:]) else {
FileHandle.standardError.write("Failed to create test PNG\\n".data(using: .utf8)!)
exit(1)
}
try png.write(to: URL(fileURLWithPath: CommandLine.arguments[1]))
`;
const genScript = path.join(tmpDir, 'gen.swift');
await writeFile(genScript, swiftSrc, 'utf8');
const genResult = spawnSync('swift', [genScript, testPng], { stdio: 'pipe', timeout: 30_000 });
if (genResult.status !== 0) {
log('Clipboard copy (image)', false, `Cannot create test image: ${genResult.stderr?.toString().trim()}`);
return;
}
const clipSrc = `import AppKit
import Foundation
guard let image = NSImage(contentsOfFile: CommandLine.arguments[1]) else {
FileHandle.standardError.write("Failed to load image\\n".data(using: .utf8)!)
exit(1)
}
let pb = NSPasteboard.general
pb.clearContents()
if !pb.writeObjects([image]) {
FileHandle.standardError.write("Failed to write to clipboard\\n".data(using: .utf8)!)
exit(1)
}
`;
const clipScript = path.join(tmpDir, 'clip.swift');
await writeFile(clipScript, clipSrc, 'utf8');
const clipResult = spawnSync('swift', [clipScript, testPng], { stdio: 'pipe', timeout: 30_000 });
if (clipResult.status === 0) {
log('Clipboard copy (image)', true, 'Can copy image to clipboard via Swift/AppKit');
} else {
log('Clipboard copy (image)', false, `Failed: ${clipResult.stderr?.toString().trim()}`);
}
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
}
async function checkPasteKeystroke(): Promise<void> {
if (process.platform === 'darwin') {
const result = spawnSync('osascript', ['-e', `
tell application "System Events"
-- Dry run: just check we CAN query key sending capability
set canSend to true
return canSend
end tell
`], { stdio: 'pipe', timeout: 10_000 });
if (result.status === 0) {
log('Paste keystroke (osascript)', true, 'System Events can send keystrokes');
} else {
const stderr = result.stderr?.toString().trim() || '';
log('Paste keystroke (osascript)', false, `Cannot send keystrokes: ${stderr}`);
}
} else if (process.platform === 'linux') {
const xdotool = spawnSync('which', ['xdotool'], { stdio: 'pipe' });
const ydotool = spawnSync('which', ['ydotool'], { stdio: 'pipe' });
if (xdotool.status === 0) {
log('Paste keystroke', true, 'xdotool available (X11)');
} else if (ydotool.status === 0) {
log('Paste keystroke', true, 'ydotool available (Wayland)');
} else {
log('Paste keystroke', false, 'No tool found. Install xdotool (X11) or ydotool (Wayland).');
}
} else if (process.platform === 'win32') {
log('Paste keystroke', true, 'Windows uses PowerShell SendKeys (built-in)');
}
}
async function checkBun(): Promise<void> {
const result = spawnSync('npx', ['-y', 'bun', '--version'], { stdio: 'pipe', timeout: 30_000 });
if (result.status === 0) {
log('Bun runtime', true, `v${result.stdout?.toString().trim()}`);
} else {
log('Bun runtime', false, 'Cannot run bun. Install: curl -fsSL https://bun.sh/install | bash');
}
}
async function checkRunningChromeConflict(): Promise<void> {
if (process.platform !== 'darwin') return;
const result = spawnSync('pgrep', ['-f', 'Google Chrome'], { stdio: 'pipe' });
const pids = result.stdout?.toString().trim().split('\n').filter(Boolean) || [];
if (pids.length > 0) {
warn('Running Chrome instances', `${pids.length} Chrome process(es) detected. The skill uses --user-data-dir for isolation, so this is safe. Paste keystroke targets Chrome by app name (minor risk if multiple Chrome windows visible).`);
} else {
log('Running Chrome instances', true, 'No existing Chrome processes');
}
}
async function main(): Promise<void> {
console.log('=== tuzi-post-to-x: Permission & Environment Check ===\n');
await checkChrome();
await checkProfileIsolation();
await checkBun();
await checkAccessibility();
await checkClipboardCopy();
await checkPasteKeystroke();
await checkRunningChromeConflict();
console.log('\n--- Summary ---');
const failed = results.filter((r) => !r.ok);
if (failed.length === 0) {
console.log('All checks passed. Ready to post to X.');
} else {
console.log(`${failed.length} issue(s) found:`);
for (const f of failed) {
console.log(` ❌ ${f.name}: ${f.detail}`);
}
process.exit(1);
}
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
const SUPPORTED_IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
function printUsage(exitCode = 0): never {
console.log(`Copy image or HTML to system clipboard
Supports:
- Image files (jpg, png, gif, webp) - copies as image data
- HTML content - copies as rich text for paste
Usage:
# Copy image to clipboard
npx -y bun copy-to-clipboard.ts image /path/to/image.jpg
# Copy HTML to clipboard
npx -y bun copy-to-clipboard.ts html "<p>Hello</p>"
# Copy HTML from file
npx -y bun copy-to-clipboard.ts html --file /path/to/content.html
`);
process.exit(exitCode);
}
function resolvePath(filePath: string): string {
return path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
}
function inferImageMimeType(imagePath: string): string {
const ext = path.extname(imagePath).toLowerCase();
switch (ext) {
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.png':
return 'image/png';
case '.gif':
return 'image/gif';
case '.webp':
return 'image/webp';
default:
return 'application/octet-stream';
}
}
type RunResult = { stdout: string; stderr: string; exitCode: number };
async function runCommand(
command: string,
args: string[],
options?: { input?: string | Buffer; allowNonZeroExit?: boolean },
): Promise<RunResult> {
return await new Promise<RunResult>((resolve, reject) => {
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk)));
child.stderr.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk)));
child.on('error', reject);
child.on('close', (code) => {
resolve({
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
stderr: Buffer.concat(stderrChunks).toString('utf8'),
exitCode: code ?? 0,
});
});
if (options?.input != null) child.stdin.write(options.input);
child.stdin.end();
}).then((result) => {
if (!options?.allowNonZeroExit && result.exitCode !== 0) {
const details = result.stderr.trim() || result.stdout.trim();
throw new Error(`Command failed (${command}): exit ${result.exitCode}${details ? `\n${details}` : ''}`);
}
return result;
});
}
async function commandExists(command: string): Promise<boolean> {
if (process.platform === 'win32') {
const result = await runCommand('where', [command], { allowNonZeroExit: true });
return result.exitCode === 0 && result.stdout.trim().length > 0;
}
const result = await runCommand('which', [command], { allowNonZeroExit: true });
return result.exitCode === 0 && result.stdout.trim().length > 0;
}
async function runCommandWithFileStdin(command: string, args: string[], filePath: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
const stderrChunks: Buffer[] = [];
const stdoutChunks: Buffer[] = [];
child.stdout.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk)));
child.stderr.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk)));
child.on('error', reject);
child.on('close', (code) => {
const exitCode = code ?? 0;
if (exitCode !== 0) {
const details = Buffer.concat(stderrChunks).toString('utf8').trim() || Buffer.concat(stdoutChunks).toString('utf8').trim();
reject(
new Error(`Command failed (${command}): exit ${exitCode}${details ? `\n${details}` : ''}`),
);
return;
}
resolve();
});
fs.createReadStream(filePath).on('error', reject).pipe(child.stdin);
});
}
async function withTempDir<T>(prefix: string, fn: (tempDir: string) => Promise<T>): Promise<T> {
const tempDir = await mkdtemp(path.join(os.tmpdir(), prefix));
try {
return await fn(tempDir);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
}
function getMacSwiftClipboardSource(): string {
return `import AppKit
import Foundation
func die(_ message: String, _ code: Int32 = 1) -> Never {
FileHandle.standardError.write(message.data(using: .utf8)!)
exit(code)
}
if CommandLine.arguments.count < 3 {
die("Usage: clipboard.swift <image|html> <path>\\n")
}
let mode = CommandLine.arguments[1]
let inputPath = CommandLine.arguments[2]
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
switch mode {
case "image":
guard let image = NSImage(contentsOfFile: inputPath) else {
die("Failed to load image: \\(inputPath)\\n")
}
if !pasteboard.writeObjects([image]) {
die("Failed to write image to clipboard\\n")
}
case "html":
let url = URL(fileURLWithPath: inputPath)
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
die("Failed to read HTML file: \\(inputPath)\\n")
}
_ = pasteboard.setData(data, forType: .html)
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
]
if let attr = try? NSAttributedString(data: data, options: options, documentAttributes: nil) {
pasteboard.setString(attr.string, forType: .string)
if let rtf = try? attr.data(
from: NSRange(location: 0, length: attr.length),
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtf]
) {
_ = pasteboard.setData(rtf, forType: .rtf)
}
} else if let html = String(data: data, encoding: .utf8) {
pasteboard.setString(html, forType: .string)
}
default:
die("Unknown mode: \\(mode)\\n")
}
`;
}
async function copyImageMac(imagePath: string): Promise<void> {
await withTempDir('copy-to-clipboard-', async (tempDir) => {
const swiftPath = path.join(tempDir, 'clipboard.swift');
await writeFile(swiftPath, getMacSwiftClipboardSource(), 'utf8');
await runCommand('swift', [swiftPath, 'image', imagePath]);
});
}
async function copyHtmlMac(htmlFilePath: string): Promise<void> {
await withTempDir('copy-to-clipboard-', async (tempDir) => {
const swiftPath = path.join(tempDir, 'clipboard.swift');
await writeFile(swiftPath, getMacSwiftClipboardSource(), 'utf8');
await runCommand('swift', [swiftPath, 'html', htmlFilePath]);
});
}
async function copyImageLinux(imagePath: string): Promise<void> {
const mime = inferImageMimeType(imagePath);
if (await commandExists('wl-copy')) {
await runCommandWithFileStdin('wl-copy', ['--type', mime], imagePath);
return;
}
if (await commandExists('xclip')) {
await runCommand('xclip', ['-selection', 'clipboard', '-t', mime, '-i', imagePath]);
return;
}
throw new Error('No clipboard tool found. Install `wl-clipboard` (wl-copy) or `xclip`.');
}
async function copyHtmlLinux(htmlFilePath: string): Promise<void> {
if (await commandExists('wl-copy')) {
await runCommandWithFileStdin('wl-copy', ['--type', 'text/html'], htmlFilePath);
return;
}
if (await commandExists('xclip')) {
await runCommand('xclip', ['-selection', 'clipboard', '-t', 'text/html', '-i', htmlFilePath]);
return;
}
throw new Error('No clipboard tool found. Install `wl-clipboard` (wl-copy) or `xclip`.');
}
async function copyImageWindows(imagePath: string): Promise<void> {
const escaped = imagePath.replace(/'/g, "''");
const ps = [
'Add-Type -AssemblyName System.Windows.Forms',
'Add-Type -AssemblyName System.Drawing',
`$img = [System.Drawing.Image]::FromFile('${escaped}')`,
'[System.Windows.Forms.Clipboard]::SetImage($img)',
'$img.Dispose()',
].join('; ');
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps]);
}
async function copyHtmlWindows(htmlFilePath: string): Promise<void> {
const escaped = htmlFilePath.replace(/'/g, "''");
const ps = [
'Add-Type -AssemblyName System.Windows.Forms',
`$html = Get-Content -Raw -LiteralPath '${escaped}'`,
'[System.Windows.Forms.Clipboard]::SetText($html, [System.Windows.Forms.TextDataFormat]::Html)',
].join('; ');
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps]);
}
async function copyImageToClipboard(imagePathInput: string): Promise<void> {
const imagePath = resolvePath(imagePathInput);
const ext = path.extname(imagePath).toLowerCase();
if (!SUPPORTED_IMAGE_EXTS.has(ext)) {
throw new Error(
`Unsupported image type: ${ext || '(none)'} (supported: ${Array.from(SUPPORTED_IMAGE_EXTS).join(', ')})`,
);
}
if (!fs.existsSync(imagePath)) throw new Error(`File not found: ${imagePath}`);
switch (process.platform) {
case 'darwin':
await copyImageMac(imagePath);
return;
case 'linux':
await copyImageLinux(imagePath);
return;
case 'win32':
await copyImageWindows(imagePath);
return;
default:
throw new Error(`Unsupported platform: ${process.platform}`);
}
}
async function copyHtmlFileToClipboard(htmlFilePathInput: string): Promise<void> {
const htmlFilePath = resolvePath(htmlFilePathInput);
if (!fs.existsSync(htmlFilePath)) throw new Error(`File not found: ${htmlFilePath}`);
switch (process.platform) {
case 'darwin':
await copyHtmlMac(htmlFilePath);
return;
case 'linux':
await copyHtmlLinux(htmlFilePath);
return;
case 'win32':
await copyHtmlWindows(htmlFilePath);
return;
default:
throw new Error(`Unsupported platform: ${process.platform}`);
}
}
async function readStdinText(): Promise<string | null> {
if (process.stdin.isTTY) return null;
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const text = Buffer.concat(chunks).toString('utf8');
return text.length > 0 ? text : null;
}
async function copyHtmlToClipboard(args: string[]): Promise<void> {
let htmlFile: string | undefined;
const positional: string[] = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i] ?? '';
if (arg === '--help' || arg === '-h') printUsage(0);
if (arg === '--file') {
htmlFile = args[i + 1];
i += 1;
continue;
}
if (arg.startsWith('--file=')) {
htmlFile = arg.slice('--file='.length);
continue;
}
if (arg === '--') {
positional.push(...args.slice(i + 1));
break;
}
if (arg.startsWith('-')) {
throw new Error(`Unknown option: ${arg}`);
}
positional.push(arg);
}
if (htmlFile && positional.length > 0) {
throw new Error('Do not pass HTML text when using --file.');
}
if (htmlFile) {
await copyHtmlFileToClipboard(htmlFile);
return;
}
const htmlFromArgs = positional.join(' ').trim();
const htmlFromStdin = (await readStdinText())?.trim() ?? '';
const html = htmlFromArgs || htmlFromStdin;
if (!html) throw new Error('Missing HTML input. Provide a string or use --file.');
await withTempDir('copy-to-clipboard-', async (tempDir) => {
const htmlPath = path.join(tempDir, 'input.html');
await writeFile(htmlPath, html, 'utf8');
await copyHtmlFileToClipboard(htmlPath);
});
}
async function main(): Promise<void> {
const argv = process.argv.slice(2);
if (argv.length === 0) printUsage(1);
const command = argv[0];
if (command === '--help' || command === '-h') printUsage(0);
if (command === 'image') {
const imagePath = argv[1];
if (!imagePath) throw new Error('Missing image path.');
await copyImageToClipboard(imagePath);
return;
}
if (command === 'html') {
await copyHtmlToClipboard(argv.slice(1));
return;
}
throw new Error(`Unknown command: ${command}`);
}
await main().catch((err) => {
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: ${message}`);
process.exit(1);
});
import fs from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import https from 'node:https';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { createHash } from 'node:crypto';
import frontMatter from 'front-matter';
import hljs from 'highlight.js/lib/common';
import { Lexer, Marked, type RendererObject, type Tokens } from 'marked';
import { unified } from 'unified';
import remarkCjkFriendly from 'remark-cjk-friendly';
import remarkParse from 'remark-parse';
import remarkStringify from 'remark-stringify';
interface ImageInfo {
placeholder: string;
localPath: string;
originalPath: string;
blockIndex: number;
}
interface ParsedMarkdown {
title: string;
coverImage: string | null;
contentImages: ImageInfo[];
html: string;
totalBlocks: number;
}
type FrontmatterFields = Record<string, unknown>;
function parseFrontmatter(content: string): { frontmatter: FrontmatterFields; body: string } {
try {
const parsed = frontMatter<FrontmatterFields>(content);
return {
frontmatter: parsed.attributes ?? {},
body: parsed.body,
};
} catch {
return { frontmatter: {}, body: content };
}
}
function stripWrappingQuotes(value: string): string {
if (!value) return value;
const doubleQuoted = value.startsWith('"') && value.endsWith('"');
const singleQuoted = value.startsWith("'") && value.endsWith("'");
const cjkDoubleQuoted = value.startsWith('\u201c') && value.endsWith('\u201d');
const cjkSingleQuoted = value.startsWith('\u2018') && value.endsWith('\u2019');
if (doubleQuoted || singleQuoted || cjkDoubleQuoted || cjkSingleQuoted) {
return value.slice(1, -1).trim();
}
return value.trim();
}
function toFrontmatterString(value: unknown): string | undefined {
if (typeof value === 'string') {
return stripWrappingQuotes(value);
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return undefined;
}
function pickFirstString(frontmatter: FrontmatterFields, keys: string[]): string | undefined {
for (const key of keys) {
const value = toFrontmatterString(frontmatter[key]);
if (value) return value;
}
return undefined;
}
function findCoverImageNearMarkdown(baseDir: string): string | null {
const candidateDirs = [baseDir, path.join(baseDir, 'imgs')];
const coverPattern = /^cover\.(png|jpe?g|webp)$/i;
for (const dir of candidateDirs) {
try {
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
continue;
}
const match = fs.readdirSync(dir).find((entry) => coverPattern.test(entry));
if (match) {
return path.join(dir, match);
}
} catch {
continue;
}
}
return null;
}
function extractTitleFromMarkdown(markdown: string): string {
const tokens = Lexer.lex(markdown, { gfm: true, breaks: true });
for (const token of tokens) {
if (token.type === 'heading' && token.depth === 1) {
return stripWrappingQuotes(token.text);
}
}
return '';
}
function downloadFile(url: string, destPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http;
const file = fs.createWriteStream(destPath);
const request = protocol.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (response) => {
if (response.statusCode === 301 || response.statusCode === 302) {
const redirectUrl = response.headers.location;
if (redirectUrl) {
file.close();
fs.unlinkSync(destPath);
downloadFile(redirectUrl, destPath).then(resolve).catch(reject);
return;
}
}
if (response.statusCode !== 200) {
file.close();
fs.unlinkSync(destPath);
reject(new Error(`Failed to download: ${response.statusCode}`));
return;
}
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
});
request.on('error', (err) => {
file.close();
fs.unlink(destPath, () => {});
reject(err);
});
request.setTimeout(30000, () => {
request.destroy();
reject(new Error('Download timeout'));
});
});
}
function getImageExtension(urlOrPath: string): string {
const match = urlOrPath.match(/\.(jpg|jpeg|png|gif|webp)(\?|$)/i);
return match ? match[1]!.toLowerCase() : 'png';
}
async function resolveImagePath(imagePath: string, baseDir: string, tempDir: string): Promise<string> {
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
const hash = createHash('md5').update(imagePath).digest('hex').slice(0, 8);
const ext = getImageExtension(imagePath);
const localPath = path.join(tempDir, `remote_${hash}.${ext}`);
if (!fs.existsSync(localPath)) {
console.error(`[md-to-html] Downloading: ${imagePath}`);
await downloadFile(imagePath, localPath);
}
return localPath;
}
if (path.isAbsolute(imagePath)) {
return imagePath;
}
return path.resolve(baseDir, imagePath);
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function highlightCode(code: string, lang: string): string {
try {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
}
return hljs.highlightAuto(code).value;
} catch {
return escapeHtml(code);
}
}
function preprocessCjkMarkdown(markdown: string): string {
try {
const processor = unified()
.use(remarkParse)
.use(remarkCjkFriendly)
.use(remarkStringify);
const result = String(processor.processSync(markdown));
return result.replace(/&#x([0-9A-Fa-f]+);/g, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)));
} catch {
return markdown;
}
}
function convertMarkdownToHtml(markdown: string, imageCallback: (src: string, alt: string) => string): { html: string; totalBlocks: number } {
const preprocessedMarkdown = preprocessCjkMarkdown(markdown);
const blockTokens = Lexer.lex(preprocessedMarkdown, { gfm: true, breaks: true });
const renderer: RendererObject = {
heading({ depth, tokens }: Tokens.Heading): string {
if (depth === 1) {
return '';
}
return `<h2>${this.parser.parseInline(tokens)}</h2>`;
},
paragraph({ tokens }: Tokens.Paragraph): string {
const text = this.parser.parseInline(tokens).trim();
if (!text) return '';
return `<p>${text}</p>`;
},
blockquote({ tokens }: Tokens.Blockquote): string {
return `<blockquote>${this.parser.parse(tokens)}</blockquote>`;
},
code({ text, lang = '' }: Tokens.Code): string {
const language = lang.split(/\s+/)[0]!.toLowerCase();
const source = text.replace(/\n$/, '');
const highlighted = highlightCode(source, language).replace(/\n/g, '<br>');
const label = language ? `<strong>[${escapeHtml(language)}]</strong><br>` : '';
return `<blockquote>${label}${highlighted}</blockquote>`;
},
image({ href, text }: Tokens.Image): string {
if (!href) return '';
return imageCallback(href, text ?? '');
},
link({ href, title, tokens, text }: Tokens.Link): string {
const label = tokens?.length ? this.parser.parseInline(tokens) : escapeHtml(text || href || '');
if (!href) return label;
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
return `<a href="${escapeHtml(href)}"${titleAttr} rel="noopener noreferrer nofollow">${label}</a>`;
},
};
const parser = new Marked({
gfm: true,
breaks: true,
});
parser.use({ renderer });
const rendered = parser.parse(preprocessedMarkdown);
if (typeof rendered !== 'string') {
throw new Error('Unexpected async markdown parse result');
}
const totalBlocks = blockTokens.filter((token) => {
if (token.type === 'space') return false;
if (token.type === 'heading' && token.depth === 1) return false;
return true;
}).length;
return {
html: rendered,
totalBlocks,
};
}
export async function parseMarkdown(
markdownPath: string,
options?: { coverImage?: string; title?: string; tempDir?: string },
): Promise<ParsedMarkdown> {
const content = fs.readFileSync(markdownPath, 'utf-8');
const baseDir = path.dirname(markdownPath);
const tempDir = options?.tempDir ?? path.join(os.tmpdir(), 'x-article-images');
await mkdir(tempDir, { recursive: true });
const { frontmatter, body } = parseFrontmatter(content);
let title = stripWrappingQuotes(options?.title ?? '') || pickFirstString(frontmatter, ['title']) || '';
if (!title) {
title = extractTitleFromMarkdown(body);
}
if (!title) {
title = path.basename(markdownPath, path.extname(markdownPath));
}
let coverImagePath = stripWrappingQuotes(options?.coverImage ?? '') || pickFirstString(frontmatter, [
'cover_image',
'coverImage',
'cover',
'image',
'featureImage',
'feature_image',
]) || null;
if (!coverImagePath) {
coverImagePath = findCoverImageNearMarkdown(baseDir);
}
const images: Array<{ src: string; alt: string; blockIndex: number }> = [];
let imageCounter = 0;
const { html, totalBlocks } = convertMarkdownToHtml(body, (src, alt) => {
const placeholder = `XIMGPH_${++imageCounter}`;
images.push({ src, alt, blockIndex: -1 });
return placeholder;
});
const htmlLines = html.split('\n');
for (let i = 0; i < images.length; i++) {
const placeholder = `XIMGPH_${i + 1}`;
for (let lineIndex = 0; lineIndex < htmlLines.length; lineIndex++) {
const regex = new RegExp(`\\b${placeholder}\\b`);
if (regex.test(htmlLines[lineIndex]!)) {
images[i]!.blockIndex = lineIndex;
break;
}
}
}
const contentImages: ImageInfo[] = [];
let firstImageAsCover: string | null = null;
for (let i = 0; i < images.length; i++) {
const img = images[i]!;
const localPath = await resolveImagePath(img.src, baseDir, tempDir);
if (i === 0 && !coverImagePath) {
firstImageAsCover = localPath;
}
contentImages.push({
placeholder: `XIMGPH_${i + 1}`,
localPath,
originalPath: img.src,
blockIndex: img.blockIndex,
});
}
const finalHtml = html.replace(/\n{3,}/g, '\n\n').trim();
let resolvedCoverImage: string | null = null;
if (coverImagePath) {
resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir);
} else if (firstImageAsCover) {
resolvedCoverImage = firstImageAsCover;
}
return {
title,
coverImage: resolvedCoverImage,
contentImages,
html: finalHtml,
totalBlocks,
};
}
function printUsage(): never {
console.log(`Convert Markdown to HTML for X Article publishing
Usage:
npx -y bun md-to-html.ts <markdown_file> [options]
Options:
--title <title> Override title from frontmatter
--cover <image> Override cover image from frontmatter
--output <json|html> Output format (default: json)
--html-only Output only the HTML content
--save-html <path> Save HTML to file
Frontmatter fields:
title: Article title (or use first H1)
cover_image: Cover image path or URL
cover: Alias for cover_image
image: Alias for cover_image
Example:
npx -y bun md-to-html.ts article.md --output json
npx -y bun md-to-html.ts article.md --html-only > /tmp/article.html
npx -y bun md-to-html.ts article.md --save-html /tmp/article.html
`);
process.exit(0);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
printUsage();
}
let markdownPath: string | undefined;
let title: string | undefined;
let coverImage: string | undefined;
let outputFormat: 'json' | 'html' = 'json';
let htmlOnly = false;
let saveHtmlPath: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--title' && args[i + 1]) {
title = args[++i];
} else if (arg === '--cover' && args[i + 1]) {
coverImage = args[++i];
} else if (arg === '--output' && args[i + 1]) {
outputFormat = args[++i] as 'json' | 'html';
} else if (arg === '--html-only') {
htmlOnly = true;
} else if (arg === '--save-html' && args[i + 1]) {
saveHtmlPath = args[++i];
} else if (!arg.startsWith('-')) {
markdownPath = arg;
}
}
if (!markdownPath) {
console.error('Error: Markdown file path required');
process.exit(1);
}
if (!fs.existsSync(markdownPath)) {
console.error(`Error: File not found: ${markdownPath}`);
process.exit(1);
}
const result = await parseMarkdown(markdownPath, { title, coverImage });
if (saveHtmlPath) {
await writeFile(saveHtmlPath, result.html, 'utf-8');
console.error(`[md-to-html] HTML saved to: ${saveHtmlPath}`);
}
if (htmlOnly) {
console.log(result.html);
} else if (outputFormat === 'html') {
console.log(result.html);
} else {
console.log(JSON.stringify(result, null, 2));
}
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
{
"name": "tuzi-post-to-x-scripts",
"private": true,
"type": "module",
"dependencies": {
"front-matter": "^4.0.2",
"highlight.js": "^11.11.1",
"marked": "^15.0.6",
"remark-cjk-friendly": "^1.1.0",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"unified": "^11.0.5"
}
}
import { spawnSync } from 'node:child_process';
import process from 'node:process';
function printUsage(exitCode = 0): never {
console.log(`Send real paste keystroke (Cmd+V / Ctrl+V) to the frontmost application
This bypasses CDP's synthetic events which websites can detect and ignore.
Usage:
npx -y bun paste-from-clipboard.ts [options]
Options:
--retries <n> Number of retry attempts (default: 3)
--delay <ms> Delay between retries in ms (default: 500)
--app <name> Target application to activate first (macOS only)
--help Show this help
Examples:
# Simple paste
npx -y bun paste-from-clipboard.ts
# Paste to Chrome with retries
npx -y bun paste-from-clipboard.ts --app "Google Chrome" --retries 5
# Quick paste with shorter delay
npx -y bun paste-from-clipboard.ts --delay 200
`);
process.exit(exitCode);
}
function sleepSync(ms: number): void {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function activateApp(appName: string): boolean {
if (process.platform !== 'darwin') return false;
// Activate and wait for app to be frontmost
const script = `
tell application "${appName}"
activate
delay 0.5
end tell
-- Verify app is frontmost
tell application "System Events"
set frontApp to name of first application process whose frontmost is true
if frontApp is not "${appName}" then
tell application "${appName}" to activate
delay 0.3
end if
end tell
`;
const result = spawnSync('osascript', ['-e', script], { stdio: 'pipe' });
return result.status === 0;
}
function pasteMac(retries: number, delayMs: number, targetApp?: string): boolean {
for (let i = 0; i < retries; i++) {
// Build script that activates app (if specified) and sends keystroke in one atomic operation
const script = targetApp
? `
tell application "${targetApp}"
activate
end tell
delay 0.3
tell application "System Events"
keystroke "v" using command down
end tell
`
: `
tell application "System Events"
keystroke "v" using command down
end tell
`;
const result = spawnSync('osascript', ['-e', script], { stdio: 'pipe' });
if (result.status === 0) {
return true;
}
const stderr = result.stderr?.toString().trim();
if (stderr) {
console.error(`[paste] osascript error: ${stderr}`);
}
if (i < retries - 1) {
console.error(`[paste] Attempt ${i + 1}/${retries} failed, retrying in ${delayMs}ms...`);
sleepSync(delayMs);
}
}
return false;
}
function pasteLinux(retries: number, delayMs: number): boolean {
// Try xdotool first (X11), then ydotool (Wayland)
const tools = [
{ cmd: 'xdotool', args: ['key', 'ctrl+v'] },
{ cmd: 'ydotool', args: ['key', '29:1', '47:1', '47:0', '29:0'] }, // Ctrl down, V down, V up, Ctrl up
];
for (const tool of tools) {
const which = spawnSync('which', [tool.cmd], { stdio: 'pipe' });
if (which.status !== 0) continue;
for (let i = 0; i < retries; i++) {
const result = spawnSync(tool.cmd, tool.args, { stdio: 'pipe' });
if (result.status === 0) {
return true;
}
if (i < retries - 1) {
console.error(`[paste] Attempt ${i + 1}/${retries} failed, retrying in ${delayMs}ms...`);
sleepSync(delayMs);
}
}
return false;
}
console.error('[paste] No supported tool found. Install xdotool (X11) or ydotool (Wayland).');
return false;
}
function pasteWindows(retries: number, delayMs: number): boolean {
const ps = `
Add-Type -AssemblyName System.Windows.Forms
[System.Windows.Forms.SendKeys]::SendWait("^v")
`;
for (let i = 0; i < retries; i++) {
const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', ps], { stdio: 'pipe' });
if (result.status === 0) {
return true;
}
if (i < retries - 1) {
console.error(`[paste] Attempt ${i + 1}/${retries} failed, retrying in ${delayMs}ms...`);
sleepSync(delayMs);
}
}
return false;
}
function paste(retries: number, delayMs: number, targetApp?: string): boolean {
switch (process.platform) {
case 'darwin':
return pasteMac(retries, delayMs, targetApp);
case 'linux':
return pasteLinux(retries, delayMs);
case 'win32':
return pasteWindows(retries, delayMs);
default:
console.error(`[paste] Unsupported platform: ${process.platform}`);
return false;
}
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
let retries = 3;
let delayMs = 500;
let targetApp: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i] ?? '';
if (arg === '--help' || arg === '-h') {
printUsage(0);
}
if (arg === '--retries' && args[i + 1]) {
retries = parseInt(args[++i]!, 10) || 3;
} else if (arg === '--delay' && args[i + 1]) {
delayMs = parseInt(args[++i]!, 10) || 500;
} else if (arg === '--app' && args[i + 1]) {
targetApp = args[++i];
} else if (arg.startsWith('-')) {
console.error(`Unknown option: ${arg}`);
printUsage(1);
}
}
if (targetApp) {
console.log(`[paste] Target app: ${targetApp}`);
}
console.log(`[paste] Sending paste keystroke (retries=${retries}, delay=${delayMs}ms)...`);
const success = paste(retries, delayMs, targetApp);
if (success) {
console.log('[paste] Paste keystroke sent successfully');
} else {
console.error('[paste] Failed to send paste keystroke');
process.exit(1);
}
}
await main();
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { parseMarkdown } from './md-to-html.js';
import {
CHROME_CANDIDATES_BASIC,
CdpConnection,
copyHtmlToClipboard,
copyImageToClipboard,
findChromeExecutable,
getDefaultProfileDir,
getFreePort,
pasteFromClipboard,
sleep,
waitForChromeDebugPort,
} from './x-utils.js';
const X_ARTICLES_URL = 'https://x.com/compose/articles';
const I18N_SELECTORS = {
titleInput: [
'textarea[placeholder="Add a title"]',
'textarea[placeholder="添加标题"]',
'textarea[placeholder="タイトルを追加"]',
'textarea[placeholder="제목 추가"]',
'textarea[name="Article Title"]',
],
addPhotosButton: [
'[aria-label="Add photos or video"]',
'[aria-label="添加照片或视频"]',
'[aria-label="写真や動画を追加"]',
'[aria-label="사진 또는 동영상 추가"]',
],
previewButton: [
'a[href*="/preview"]',
'[data-testid="previewButton"]',
'button[aria-label*="preview" i]',
'button[aria-label*="预览" i]',
'button[aria-label*="プレビュー" i]',
'button[aria-label*="미리보기" i]',
],
publishButton: [
'[data-testid="publishButton"]',
'button[aria-label*="publish" i]',
'button[aria-label*="发布" i]',
'button[aria-label*="公開" i]',
'button[aria-label*="게시" i]',
],
};
interface ArticleOptions {
markdownPath: string;
coverImage?: string;
title?: string;
submit?: boolean;
profileDir?: string;
chromePath?: string;
}
async function findExistingDebugPort(profileDir: string): Promise<number | null> {
const portFile = path.join(profileDir, 'DevToolsActivePort');
if (!fs.existsSync(portFile)) return null;
try {
const content = fs.readFileSync(portFile, 'utf-8').trim();
if (!content) return null;
const [portLine] = content.split(/\r?\n/);
const port = Number(portLine);
if (!Number.isFinite(port) || port <= 0) return null;
// Verify the port is actually active.
await waitForChromeDebugPort(port, 1500, { includeLastError: true });
return port;
} catch {
return null;
}
}
export async function publishArticle(options: ArticleOptions): Promise<void> {
const { markdownPath, submit = false, profileDir = getDefaultProfileDir() } = options;
console.log('[x-article] Parsing markdown...');
const parsed = await parseMarkdown(markdownPath, {
title: options.title,
coverImage: options.coverImage,
});
console.log(`[x-article] Title: ${parsed.title}`);
console.log(`[x-article] Cover: ${parsed.coverImage ?? 'none'}`);
console.log(`[x-article] Content images: ${parsed.contentImages.length}`);
// Save HTML to temp file
const htmlPath = path.join(os.tmpdir(), 'x-article-content.html');
await writeFile(htmlPath, parsed.html, 'utf-8');
console.log(`[x-article] HTML saved to: ${htmlPath}`);
const chromePath = options.chromePath ?? findChromeExecutable(CHROME_CANDIDATES_BASIC);
if (!chromePath) throw new Error('Chrome not found');
await mkdir(profileDir, { recursive: true });
const existingPort = await findExistingDebugPort(profileDir);
const port = existingPort ?? await getFreePort();
if (existingPort) {
console.log(`[x-article] Reusing existing Chrome instance on port ${port}`);
} else {
console.log(`[x-article] Launching Chrome...`);
const chromeArgs = [
`--remote-debugging-port=${port}`,
`--user-data-dir=${profileDir}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-blink-features=AutomationControlled',
'--start-maximized',
X_ARTICLES_URL,
];
if (process.platform === 'darwin') {
const appPath = chromePath.replace(/\/Contents\/MacOS\/Google Chrome$/, '');
spawn('open', ['-na', appPath, '--args', ...chromeArgs], { stdio: 'ignore' });
} else {
spawn(chromePath, chromeArgs, { stdio: 'ignore' });
}
}
let cdp: CdpConnection | null = null;
try {
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 60_000 });
// Get page target
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.startsWith(X_ARTICLES_URL));
if (!pageTarget) {
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: X_ARTICLES_URL });
pageTarget = { targetId, url: X_ARTICLES_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 });
console.log('[x-article] Waiting for articles page...');
await sleep(1000);
// Wait for and click "create" button
const waitForElement = async (selector: string, timeoutMs = 60_000): Promise<boolean> => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('${selector}')`,
returnByValue: true,
}, { sessionId });
if (result.result.value) return true;
await sleep(500);
}
return false;
};
const clickElement = async (selector: string): Promise<boolean> => {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `(() => { const el = document.querySelector('${selector}'); if (el) { el.click(); return true; } return false; })()`,
returnByValue: true,
}, { sessionId });
return result.result.value;
};
const typeText = async (selector: string, text: string): Promise<void> => {
await cdp!.send('Runtime.evaluate', {
expression: `(() => {
const el = document.querySelector('${selector}');
if (el) {
el.focus();
document.execCommand('insertText', false, ${JSON.stringify(text)});
}
})()`,
}, { sessionId });
};
const pressKey = async (key: string, modifiers = 0): Promise<void> => {
await cdp!.send('Input.dispatchKeyEvent', {
type: 'keyDown',
key,
code: `Key${key.toUpperCase()}`,
modifiers,
windowsVirtualKeyCode: key.toUpperCase().charCodeAt(0),
}, { sessionId });
await cdp!.send('Input.dispatchKeyEvent', {
type: 'keyUp',
key,
code: `Key${key.toUpperCase()}`,
modifiers,
windowsVirtualKeyCode: key.toUpperCase().charCodeAt(0),
}, { sessionId });
};
// Check if we're on the articles list page (has Write button)
console.log('[x-article] Looking for Write button...');
const writeButtonFound = await waitForElement('[data-testid="empty_state_button_text"]', 10_000);
if (writeButtonFound) {
console.log('[x-article] Clicking Write button...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="empty_state_button_text"]')?.click()`,
}, { sessionId });
await sleep(2000);
}
// Wait for editor (title textarea)
const titleSelectors = I18N_SELECTORS.titleInput.join(', ');
console.log('[x-article] Waiting for editor...');
const editorFound = await waitForElement(titleSelectors, 30_000);
if (!editorFound) {
console.log('[x-article] Editor not found. Please ensure you have X Premium and are logged in.');
await sleep(60_000);
throw new Error('Editor not found');
}
// Upload cover image
if (parsed.coverImage) {
console.log('[x-article] Uploading cover image...');
// Click "Add photos or video" button
const addPhotosSelectors = JSON.stringify(I18N_SELECTORS.addPhotosButton);
await cdp.send('Runtime.evaluate', {
expression: `(() => {
const selectors = ${addPhotosSelectors};
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) { el.click(); return true; }
}
return false;
})()`,
}, { sessionId });
await sleep(500);
// Use file input directly
const { root } = await cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {}, { sessionId });
const { nodeId } = await cdp.send<{ nodeId: number }>('DOM.querySelector', {
nodeId: root.nodeId,
selector: '[data-testid="fileInput"], input[type="file"][accept*="image"]',
}, { sessionId });
if (nodeId) {
await cdp.send('DOM.setFileInputFiles', {
nodeId,
files: [parsed.coverImage],
}, { sessionId });
console.log('[x-article] Cover image file set');
// Wait for Apply button to appear and click it
console.log('[x-article] Waiting for Apply button...');
const applyFound = await waitForElement('[data-testid="applyButton"]', 15_000);
if (applyFound) {
// Check if modal is present
const isModalOpen = async (): Promise<boolean> => {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('[role="dialog"][aria-modal="true"]')`,
returnByValue: true,
}, { sessionId });
return result.result.value;
};
// Click Apply button with retry logic
const maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
console.log(`[x-article] Clicking Apply button (attempt ${attempt}/${maxRetries})...`);
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="applyButton"]')?.click()`,
}, { sessionId });
// Wait for modal to close (up to 5 seconds per attempt)
const closeTimeout = 5000;
const checkInterval = 300;
const startTime = Date.now();
let modalClosed = false;
while (Date.now() - startTime < closeTimeout) {
await sleep(checkInterval);
const stillOpen = await isModalOpen();
if (!stillOpen) {
modalClosed = true;
break;
}
}
if (modalClosed) {
console.log('[x-article] Cover image applied, modal closed');
await sleep(500);
break;
}
if (attempt < maxRetries) {
console.log('[x-article] Modal still open, retrying...');
} else {
console.log('[x-article] Modal did not close after all attempts, continuing anyway...');
}
}
} else {
console.log('[x-article] Apply button not found, continuing...');
}
}
}
// Fill title using keyboard input
if (parsed.title) {
console.log('[x-article] Filling title...');
// Focus title input
const titleInputSelectors = JSON.stringify(I18N_SELECTORS.titleInput);
await cdp.send('Runtime.evaluate', {
expression: `(() => {
const selectors = ${titleInputSelectors};
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) { el.focus(); return true; }
}
return false;
})()`,
}, { sessionId });
await sleep(200);
// Type title character by character using insertText
await cdp.send('Input.insertText', { text: parsed.title }, { sessionId });
await sleep(300);
// Tab out to trigger save
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 }, { sessionId });
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 }, { sessionId });
await sleep(500);
}
// Insert HTML content
console.log('[x-article] Inserting content...');
// Read HTML content
const htmlContent = fs.readFileSync(htmlPath, 'utf-8');
// Focus on DraftEditor body
await cdp.send('Runtime.evaluate', {
expression: `(() => {
const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');
if (editor) {
editor.focus();
editor.click();
return true;
}
return false;
})()`,
}, { sessionId });
await sleep(300);
// Method 1: Simulate paste event with HTML data
console.log('[x-article] Attempting to insert HTML via paste event...');
const pasteResult = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `(() => {
const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');
if (!editor) return false;
const html = ${JSON.stringify(htmlContent)};
// Create a paste event with HTML data
const dt = new DataTransfer();
dt.setData('text/html', html);
dt.setData('text/plain', html.replace(/<[^>]*>/g, ''));
const pasteEvent = new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: dt
});
editor.dispatchEvent(pasteEvent);
return true;
})()`,
returnByValue: true,
}, { sessionId });
await sleep(1000);
// Check if content was inserted
const contentCheck = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {
expression: `document.querySelector('.DraftEditor-editorContainer [data-contents="true"]')?.innerText?.length || 0`,
returnByValue: true,
}, { sessionId });
if (contentCheck.result.value > 50) {
console.log(`[x-article] Content inserted successfully (${contentCheck.result.value} chars)`);
} else {
console.log('[x-article] Paste event may not have worked, trying insertHTML...');
// Method 2: Use execCommand insertHTML
await cdp.send('Runtime.evaluate', {
expression: `(() => {
const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');
if (!editor) return false;
editor.focus();
document.execCommand('insertHTML', false, ${JSON.stringify(htmlContent)});
return true;
})()`,
}, { sessionId });
await sleep(1000);
// Check again
const check2 = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {
expression: `document.querySelector('.DraftEditor-editorContainer [data-contents="true"]')?.innerText?.length || 0`,
returnByValue: true,
}, { sessionId });
if (check2.result.value > 50) {
console.log(`[x-article] Content inserted via execCommand (${check2.result.value} chars)`);
} else {
console.log('[x-article] Auto-insert failed. HTML copied to clipboard - please paste manually (Cmd+V)');
copyHtmlToClipboard(htmlPath);
// Wait for manual paste
console.log('[x-article] Waiting 30s for manual paste...');
await sleep(30_000);
}
}
// Insert content images (reverse order to maintain positions)
if (parsed.contentImages.length > 0) {
console.log('[x-article] Inserting content images...');
// First, check what placeholders exist in the editor
const editorContent = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
expression: `document.querySelector('.DraftEditor-editorContainer [data-contents="true"]')?.innerText || ''`,
returnByValue: true,
}, { sessionId });
console.log('[x-article] Checking for placeholders in content...');
for (const img of parsed.contentImages) {
// Use regex for exact match (not followed by digit, e.g., XIMGPH_1 should not match XIMGPH_10)
const regex = new RegExp(img.placeholder + '(?!\\d)');
if (regex.test(editorContent.result.value)) {
console.log(`[x-article] Found: ${img.placeholder}`);
} else {
console.log(`[x-article] NOT found: ${img.placeholder}`);
}
}
// Process images in XIMGPH order (1, 2, 3, ...) regardless of blockIndex
const getPlaceholderIndex = (placeholder: string): number => {
const match = placeholder.match(/XIMGPH_(\d+)/);
return match ? Number(match[1]) : Number.POSITIVE_INFINITY;
};
const sortedImages = [...parsed.contentImages].sort(
(a, b) => getPlaceholderIndex(a.placeholder) - getPlaceholderIndex(b.placeholder),
);
for (let i = 0; i < sortedImages.length; i++) {
const img = sortedImages[i]!;
console.log(`[x-article] [${i + 1}/${sortedImages.length}] Inserting image at placeholder: ${img.placeholder}`);
// Helper to select placeholder with retry
const selectPlaceholder = async (maxRetries = 3): Promise<boolean> => {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
// Find, scroll to, and select the placeholder text in DraftEditor
await cdp!.send('Runtime.evaluate', {
expression: `(() => {
const editor = document.querySelector('.DraftEditor-editorContainer [data-contents="true"]');
if (!editor) return false;
const placeholder = ${JSON.stringify(img.placeholder)};
// Search through all text nodes in the editor
const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false);
let node;
while ((node = walker.nextNode())) {
const text = node.textContent || '';
let searchStart = 0;
let idx;
// Search for exact match (not prefix of longer placeholder like XIMGPH_1 in XIMGPH_10)
while ((idx = text.indexOf(placeholder, searchStart)) !== -1) {
const afterIdx = idx + placeholder.length;
const charAfter = text[afterIdx];
// Exact match if next char is not a digit (XIMGPH_1 should not match XIMGPH_10)
if (charAfter === undefined || !/\\d/.test(charAfter)) {
// Found exact placeholder - scroll to it first
const parentElement = node.parentElement;
if (parentElement) {
parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// Select it
const range = document.createRange();
range.setStart(node, idx);
range.setEnd(node, idx + placeholder.length);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
return true;
}
searchStart = afterIdx;
}
}
return false;
})()`,
}, { sessionId });
// Wait for scroll and selection to settle
await sleep(800);
// Verify selection matches the placeholder
const selectionCheck = await cdp!.send<{ result: { value: string } }>('Runtime.evaluate', {
expression: `window.getSelection()?.toString() || ''`,
returnByValue: true,
}, { sessionId });
const selectedText = selectionCheck.result.value.trim();
if (selectedText === img.placeholder) {
console.log(`[x-article] Selection verified: "${selectedText}"`);
return true;
}
if (attempt < maxRetries) {
console.log(`[x-article] Selection attempt ${attempt} got "${selectedText}", retrying...`);
await sleep(500);
} else {
console.warn(`[x-article] Selection failed after ${maxRetries} attempts, got: "${selectedText}"`);
}
}
return false;
};
// Try to select the placeholder
const selected = await selectPlaceholder(3);
if (!selected) {
console.warn(`[x-article] Skipping image - could not select placeholder: ${img.placeholder}`);
continue;
}
console.log(`[x-article] Copying image: ${path.basename(img.localPath)}`);
// Copy image to clipboard
if (!copyImageToClipboard(img.localPath)) {
console.warn(`[x-article] Failed to copy image to clipboard`);
continue;
}
// Wait for clipboard to be fully ready
await sleep(1000);
// Delete placeholder using execCommand (more reliable than keyboard events for DraftJS)
console.log(`[x-article] Deleting placeholder...`);
const deleteResult = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `(() => {
const sel = window.getSelection();
if (!sel || sel.isCollapsed) return false;
// Try execCommand delete first
if (document.execCommand('delete', false)) return true;
// Fallback: replace selection with empty using insertText
document.execCommand('insertText', false, '');
return true;
})()`,
returnByValue: true,
}, { sessionId });
await sleep(500);
// Check that placeholder is no longer in editor (exact match, not substring)
const afterDelete = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `(() => {
const editor = document.querySelector('.DraftEditor-editorContainer [data-contents="true"]');
if (!editor) return true;
const text = editor.innerText;
const placeholder = ${JSON.stringify(img.placeholder)};
// Use regex to find exact match (not followed by digit)
const regex = new RegExp(placeholder + '(?!\\\\d)');
return !regex.test(text);
})()`,
returnByValue: true,
}, { sessionId });
if (!afterDelete.result.value) {
console.warn(`[x-article] Placeholder may not have been deleted, trying dispatchEvent...`);
// Try selecting and deleting with InputEvent
await selectPlaceholder(1);
await sleep(300);
await cdp.send('Runtime.evaluate', {
expression: `(() => {
const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');
if (!editor) return;
editor.focus();
// Dispatch beforeinput and input events for deletion
const beforeEvent = new InputEvent('beforeinput', { inputType: 'deleteContentBackward', bubbles: true, cancelable: true });
editor.dispatchEvent(beforeEvent);
const inputEvent = new InputEvent('input', { inputType: 'deleteContentBackward', bubbles: true });
editor.dispatchEvent(inputEvent);
})()`,
}, { sessionId });
await sleep(500);
}
// Count existing image blocks before paste
const imgCountBefore = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {
expression: `document.querySelectorAll('section[data-block="true"][contenteditable="false"] img[src^="blob:"]').length`,
returnByValue: true,
}, { sessionId });
// Focus editor to ensure cursor is in position
await cdp.send('Runtime.evaluate', {
expression: `(() => {
const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');
if (editor) editor.focus();
})()`,
}, { sessionId });
await sleep(300);
// Paste image using paste script (activates Chrome, sends real keystroke)
console.log(`[x-article] Pasting image...`);
if (pasteFromClipboard('Google Chrome', 5, 1000)) {
console.log(`[x-article] Image pasted: ${path.basename(img.localPath)}`);
} else {
console.warn(`[x-article] Failed to paste image after retries`);
}
// Verify image appeared in editor
console.log(`[x-article] 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('section[data-block="true"][contenteditable="false"] img[src^="blob:"]').length`,
returnByValue: true,
}, { sessionId });
if (r.result.value >= expectedImgCount) {
imgUploadOk = true;
break;
}
await sleep(1000);
}
if (imgUploadOk) {
console.log(`[x-article] Image upload verified (${expectedImgCount} image block(s))`);
// Wait for DraftEditor DOM to stabilize after image insertion
await sleep(3000);
} else {
console.warn(`[x-article] Image upload not detected after 15s`);
if (i === 0) {
console.error('[x-article] First image paste failed. Run check-paste-permissions.ts to diagnose.');
}
}
}
console.log('[x-article] All images processed.');
// Final verification: check placeholder residue and image count
console.log('[x-article] Running post-composition verification...');
const finalEditorContent = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
expression: `document.querySelector('.DraftEditor-editorContainer [data-contents="true"]')?.innerText || ''`,
returnByValue: true,
}, { sessionId });
const remainingPlaceholders: string[] = [];
for (const img of parsed.contentImages) {
const regex = new RegExp(img.placeholder + '(?!\\d)');
if (regex.test(finalEditorContent.result.value)) {
remainingPlaceholders.push(img.placeholder);
}
}
const finalImgCount = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {
expression: `document.querySelectorAll('section[data-block="true"][contenteditable="false"] img[src^="blob:"]').length`,
returnByValue: true,
}, { sessionId });
const expectedCount = parsed.contentImages.length;
const actualCount = finalImgCount.result.value;
if (remainingPlaceholders.length > 0 || actualCount < expectedCount) {
console.warn('[x-article] ⚠ POST-COMPOSITION CHECK FAILED:');
if (remainingPlaceholders.length > 0) {
console.warn(`[x-article] Remaining placeholders: ${remainingPlaceholders.join(', ')}`);
}
if (actualCount < expectedCount) {
console.warn(`[x-article] Image count: expected ${expectedCount}, found ${actualCount}`);
}
console.warn('[x-article] Please check the article before publishing.');
} else {
console.log(`[x-article] ✓ Verification passed: ${actualCount} image(s), no remaining placeholders.`);
}
}
// Before preview: blur editor to trigger save
console.log('[x-article] Triggering content save...');
await cdp.send('Runtime.evaluate', {
expression: `(() => {
// Blur editor to trigger any pending saves
const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');
if (editor) {
editor.blur();
}
// Also click elsewhere to ensure focus is lost
document.body.click();
})()`,
}, { sessionId });
await sleep(1500);
// Click Preview button
console.log('[x-article] Opening preview...');
const previewSelectors = JSON.stringify(I18N_SELECTORS.previewButton);
const previewClicked = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `(() => {
const selectors = ${previewSelectors};
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) { el.click(); return true; }
}
return false;
})()`,
returnByValue: true,
}, { sessionId });
if (previewClicked.result.value) {
console.log('[x-article] Preview opened');
await sleep(3000);
} else {
console.log('[x-article] Preview button not found');
}
// Check for publish button
if (submit) {
console.log('[x-article] Publishing...');
const publishSelectors = JSON.stringify(I18N_SELECTORS.publishButton);
await cdp.send('Runtime.evaluate', {
expression: `(() => {
const selectors = ${publishSelectors};
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el && !el.disabled) { el.click(); return true; }
}
return false;
})()`,
}, { sessionId });
await sleep(3000);
console.log('[x-article] Article published!');
} else {
console.log('[x-article] Article composed (draft mode).');
console.log('[x-article] Browser remains open for manual review.');
}
} finally {
// Disconnect CDP but keep browser open
if (cdp) {
cdp.close();
}
// Don't kill Chrome - let user review and close manually
}
}
function printUsage(): never {
console.log(`Publish Markdown article to X (Twitter) Articles
Usage:
npx -y bun x-article.ts <markdown_file> [options]
Options:
--title <title> Override title
--cover <image> Override cover image
--submit Actually publish (default: draft only)
--profile <dir> Chrome profile directory
--help Show this help
Markdown frontmatter:
---
title: My Article Title
cover_image: /path/to/cover.jpg
---
Example:
npx -y bun x-article.ts article.md
npx -y bun x-article.ts article.md --cover ./hero.png
npx -y bun x-article.ts article.md --submit
`);
process.exit(0);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
printUsage();
}
let markdownPath: string | undefined;
let title: string | undefined;
let coverImage: string | undefined;
let submit = false;
let profileDir: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--title' && args[i + 1]) {
title = args[++i];
} else if (arg === '--cover' && args[i + 1]) {
const raw = args[++i]!;
coverImage = path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);
} else if (arg === '--submit') {
submit = true;
} else if (arg === '--profile' && args[i + 1]) {
profileDir = args[++i];
} else if (!arg.startsWith('-')) {
markdownPath = arg;
}
}
if (!markdownPath) {
console.error('Error: Markdown file path required');
process.exit(1);
}
if (!fs.existsSync(markdownPath)) {
console.error(`Error: File not found: ${markdownPath}`);
process.exit(1);
}
await publishArticle({ markdownPath, title, coverImage, submit, profileDir });
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import { mkdir } from 'node:fs/promises';
import process from 'node:process';
import {
CHROME_CANDIDATES_FULL,
CdpConnection,
copyImageToClipboard,
findChromeExecutable,
getDefaultProfileDir,
getFreePort,
pasteFromClipboard,
sleep,
waitForChromeDebugPort,
} from './x-utils.js';
const X_COMPOSE_URL = 'https://x.com/compose/post';
interface XBrowserOptions {
text?: string;
images?: string[];
submit?: boolean;
timeoutMs?: number;
profileDir?: string;
chromePath?: string;
}
export async function postToX(options: XBrowserOptions): Promise<void> {
const { text, images = [], submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;
const chromePath = options.chromePath ?? findChromeExecutable(CHROME_CANDIDATES_FULL);
if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.');
await mkdir(profileDir, { recursive: true });
const port = await getFreePort();
console.log(`[x-browser] 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, { includeLastError: true });
cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 15_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('Input.setIgnoreInputEvents', { ignore: false }, { sessionId });
console.log('[x-browser] Waiting for X editor...');
await sleep(3000);
const waitForEditor = async (): Promise<boolean> => {
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-browser] Editor not found. Please log in to X in the browser window.');
console.log('[x-browser] Waiting for login...');
const loggedIn = await waitForEditor();
if (!loggedIn) throw new Error('Timed out waiting for X editor. Please log in first.');
}
if (text) {
console.log('[x-browser] 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);
}
for (const imagePath of images) {
if (!fs.existsSync(imagePath)) {
console.warn(`[x-browser] Image not found: ${imagePath}`);
continue;
}
console.log(`[x-browser] Pasting image: ${imagePath}`);
if (!copyImageToClipboard(imagePath)) {
console.warn(`[x-browser] Failed to copy image to clipboard: ${imagePath}`);
continue;
}
// Count uploaded images before paste
const imgCountBefore = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {
expression: `document.querySelectorAll('img[src^="blob:"]').length`,
returnByValue: true,
}, { sessionId });
// Wait for clipboard to be ready
await sleep(500);
// 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,
}, { sessionId });
await cdp.send('Input.dispatchKeyEvent', {
type: 'keyUp',
key: 'v',
code: 'KeyV',
modifiers,
windowsVirtualKeyCode: 86,
}, { sessionId });
}
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);
}
if (imgUploadOk) {
console.log('[x-browser] Image upload verified');
} else {
console.warn('[x-browser] Image upload not detected after 15s. Run check-paste-permissions.ts to diagnose.');
}
}
if (submit) {
console.log('[x-browser] Submitting post...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,
}, { sessionId });
await sleep(2000);
console.log('[x-browser] Post submitted!');
} 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...');
await sleep(30_000);
}
} finally {
if (cdp) {
try { await cdp.send('Browser.close', {}, { timeoutMs: 5_000 }); } catch {}
cdp.close();
}
setTimeout(() => {
if (!chrome.killed) try { chrome.kill('SIGKILL'); } catch {}
}, 2_000).unref?.();
try { chrome.kill('SIGTERM'); } catch {}
}
}
function printUsage(): never {
console.log(`Post to X (Twitter) using real Chrome browser
Usage:
npx -y bun x-browser.ts [options] [text]
Options:
--image <path> Add image (can be repeated, max 4)
--submit Actually post (default: preview only)
--profile <dir> Chrome profile directory
--help Show this help
Examples:
npx -y bun x-browser.ts "Hello from CLI!"
npx -y bun x-browser.ts "Check this out" --image ./screenshot.png
npx -y bun x-browser.ts "Post it!" --image a.png --image b.png --submit
`);
process.exit(0);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) printUsage();
const images: string[] = [];
let submit = false;
let profileDir: string | undefined;
const textParts: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--image' && args[i + 1]) {
images.push(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 (!text && images.length === 0) {
console.error('Error: Provide text or at least one image.');
process.exit(1);
}
await postToX({ text, images, submit, profileDir });
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
import { spawn } from 'node:child_process';
import { mkdir } from 'node:fs/promises';
import process from 'node:process';
import {
CHROME_CANDIDATES_FULL,
CdpConnection,
findChromeExecutable,
getDefaultProfileDir,
getFreePort,
sleep,
waitForChromeDebugPort,
} from './x-utils.js';
function extractTweetUrl(urlOrId: string): string | null {
// If it's already a full URL, normalize it
if (urlOrId.match(/(?:x\.com|twitter\.com)\/\w+\/status\/\d+/)) {
return urlOrId.replace(/twitter\.com/, 'x.com').split('?')[0];
}
return null;
}
interface QuoteOptions {
tweetUrl: string;
comment?: string;
submit?: boolean;
timeoutMs?: number;
profileDir?: string;
chromePath?: string;
}
export async function quotePost(options: QuoteOptions): Promise<void> {
const { tweetUrl, comment, submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;
const chromePath = options.chromePath ?? findChromeExecutable(CHROME_CANDIDATES_FULL);
if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.');
await mkdir(profileDir, { recursive: true });
const port = await getFreePort();
console.log(`[x-quote] Launching Chrome (profile: ${profileDir})`);
console.log(`[x-quote] Opening tweet: ${tweetUrl}`);
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',
tweetUrl,
], { stdio: 'ignore' });
let cdp: CdpConnection | null = null;
try {
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 15_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: tweetUrl });
pageTarget = { targetId, url: tweetUrl, 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 });
console.log('[x-quote] Waiting for tweet to load...');
await sleep(3000);
// Wait for retweet button to appear (indicates tweet loaded and user logged in)
const waitForRetweetButton = async (): Promise<boolean> => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('[data-testid="retweet"]')`,
returnByValue: true,
}, { sessionId });
if (result.result.value) return true;
await sleep(1000);
}
return false;
};
const retweetFound = await waitForRetweetButton();
if (!retweetFound) {
console.log('[x-quote] Tweet not found or not logged in. Please log in to X in the browser window.');
console.log('[x-quote] Waiting for login...');
const loggedIn = await waitForRetweetButton();
if (!loggedIn) throw new Error('Timed out waiting for tweet. Please log in first or check the tweet URL.');
}
// Click the retweet button
console.log('[x-quote] Clicking retweet button...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="retweet"]')?.click()`,
}, { sessionId });
await sleep(1000);
// Wait for and click the "Quote" option in the menu
console.log('[x-quote] Selecting quote option...');
const waitForQuoteOption = async (): Promise<boolean> => {
const start = Date.now();
while (Date.now() - start < 10_000) {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('[data-testid="Dropdown"] [role="menuitem"]:nth-child(2)')`,
returnByValue: true,
}, { sessionId });
if (result.result.value) return true;
await sleep(200);
}
return false;
};
const quoteOptionFound = await waitForQuoteOption();
if (!quoteOptionFound) {
throw new Error('Quote option not found. The menu may not have opened.');
}
// Click the quote option (second menu item)
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="Dropdown"] [role="menuitem"]:nth-child(2)')?.click()`,
}, { sessionId });
await sleep(2000);
// Wait for the quote compose dialog
console.log('[x-quote] Waiting for quote compose dialog...');
const waitForQuoteDialog = async (): Promise<boolean> => {
const start = Date.now();
while (Date.now() - start < 10_000) {
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(200);
}
return false;
};
const dialogFound = await waitForQuoteDialog();
if (!dialogFound) {
throw new Error('Quote compose dialog not found.');
}
// Type the comment if provided
if (comment) {
console.log('[x-quote] Typing comment...');
// Use CDP Input.insertText for more reliable text insertion
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetTextarea_0"]')?.focus()`,
}, { sessionId });
await sleep(200);
await cdp.send('Input.insertText', {
text: comment,
}, { sessionId });
await sleep(500);
}
if (submit) {
console.log('[x-quote] Submitting quote post...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,
}, { sessionId });
await sleep(2000);
console.log('[x-quote] Quote post submitted!');
} else {
console.log('[x-quote] Quote composed (preview mode). Add --submit to post.');
console.log('[x-quote] Browser will stay open for 30 seconds for preview...');
await sleep(30_000);
}
} finally {
if (cdp) {
try { await cdp.send('Browser.close', {}, { timeoutMs: 5_000 }); } catch {}
cdp.close();
}
setTimeout(() => {
if (!chrome.killed) try { chrome.kill('SIGKILL'); } catch {}
}, 2_000).unref?.();
try { chrome.kill('SIGTERM'); } catch {}
}
}
function printUsage(): never {
console.log(`Quote a tweet on X (Twitter) using real Chrome browser
Usage:
npx -y bun x-quote.ts <tweet-url> [options] [comment]
Options:
--submit Actually post (default: preview only)
--profile <dir> Chrome profile directory
--help Show this help
Examples:
npx -y bun x-quote.ts https://x.com/user/status/123456789 "Great insight!"
npx -y bun x-quote.ts https://x.com/user/status/123456789 "I agree!" --submit
`);
process.exit(0);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) printUsage();
let tweetUrl: string | undefined;
let submit = false;
let profileDir: string | undefined;
const commentParts: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--submit') {
submit = true;
} else if (arg === '--profile' && args[i + 1]) {
profileDir = args[++i];
} else if (!arg.startsWith('-')) {
// First non-option argument is the tweet URL
if (!tweetUrl && arg.match(/(?:x\.com|twitter\.com)\/\w+\/status\/\d+/)) {
tweetUrl = extractTweetUrl(arg) ?? undefined;
} else {
commentParts.push(arg);
}
}
}
if (!tweetUrl) {
console.error('Error: Please provide a tweet URL.');
console.error('Example: npx -y bun x-quote.ts https://x.com/user/status/123456789 "Your comment"');
process.exit(1);
}
const comment = commentParts.join(' ').trim() || undefined;
await quotePost({ tweetUrl, comment, submit, profileDir });
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
import { execSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
export type PlatformCandidates = {
darwin?: string[];
win32?: string[];
default: string[];
};
export const CHROME_CANDIDATES_BASIC: PlatformCandidates = {
darwin: [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
],
win32: [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
],
default: [
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
],
};
export const CHROME_CANDIDATES_FULL: PlatformCandidates = {
darwin: [
'/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',
],
win32: [
'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',
],
default: [
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/snap/bin/chromium',
'/usr/bin/microsoft-edge',
],
};
function getCandidatesForPlatform(candidates: PlatformCandidates): string[] {
if (process.platform === 'darwin' && candidates.darwin?.length) return candidates.darwin;
if (process.platform === 'win32' && candidates.win32?.length) return candidates.win32;
return candidates.default;
}
export function findChromeExecutable(candidates: PlatformCandidates): string | undefined {
const override = process.env.X_BROWSER_CHROME_PATH?.trim();
if (override && fs.existsSync(override)) return override;
for (const candidate of getCandidatesForPlatform(candidates)) {
if (fs.existsSync(candidate)) return candidate;
}
return undefined;
}
let _wslHome: string | null | undefined;
function getWslWindowsHome(): string | null {
if (_wslHome !== undefined) return _wslHome;
if (!process.env.WSL_DISTRO_NAME) { _wslHome = null; return null; }
try {
const raw = execSync('cmd.exe /C "echo %USERPROFILE%"', { encoding: 'utf-8', timeout: 5000 }).trim().replace(/\r/g, '');
_wslHome = execSync(`wslpath -u "${raw}"`, { encoding: 'utf-8', timeout: 5000 }).trim() || null;
} catch { _wslHome = null; }
return _wslHome;
}
export function getDefaultProfileDir(): string {
const override = process.env.X_BROWSER_PROFILE_DIR?.trim();
if (override) return path.resolve(override);
const home = getWslWindowsHome() ?? os.homedir();
const base = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share');
return path.join(base, 'x-browser-profile');
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function getFreePort(): Promise<number> {
const fixed = parseInt(process.env.X_BROWSER_DEBUG_PORT || '', 10);
if (fixed > 0) return fixed;
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);
});
});
});
}
async function fetchJson<T = unknown>(url: string): Promise<T> {
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;
}
export async function waitForChromeDebugPort(
port: number,
timeoutMs: number,
options?: { includeLastError?: boolean },
): Promise<string> {
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);
}
if (options?.includeLastError && lastError) {
throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}
throw new Error('Chrome debug port not ready');
}
type PendingRequest = {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout> | null;
};
export class CdpConnection {
private ws: WebSocket;
private nextId = 0;
private pending = new Map<number, PendingRequest>();
private eventHandlers = new Map<string, Set<(params: unknown) => void>>();
private defaultTimeoutMs: number;
private constructor(ws: WebSocket, options?: { defaultTimeoutMs?: number }) {
this.ws = ws;
this.defaultTimeoutMs = options?.defaultTimeoutMs ?? 15_000;
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; method?: string; params?: unknown; result?: unknown; error?: { message?: string } };
if (msg.method) {
const handlers = this.eventHandlers.get(msg.method);
if (handlers) handlers.forEach((h) => h(msg.params));
}
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, options?: { defaultTimeoutMs?: number }): Promise<CdpConnection> {
const ws = new WebSocket(url);
await new Promise<void>((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, options);
}
on(method: string, handler: (params: unknown) => void): void {
if (!this.eventHandlers.has(method)) this.eventHandlers.set(method, new Set());
this.eventHandlers.get(method)!.add(handler);
}
async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: { sessionId?: string; timeoutMs?: number }): Promise<T> {
const id = ++this.nextId;
const message: Record<string, unknown> = { id, method };
if (params) message.params = params;
if (options?.sessionId) message.sessionId = options.sessionId;
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
const result = await new Promise<unknown>((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 {}
}
}
export function getScriptDir(): string {
return path.dirname(fileURLToPath(import.meta.url));
}
function runBunScript(scriptPath: string, args: string[]): boolean {
const result = spawnSync('npx', ['-y', 'bun', scriptPath, ...args], { stdio: 'inherit' });
return result.status === 0;
}
export function copyImageToClipboard(imagePath: string): boolean {
const copyScript = path.join(getScriptDir(), 'copy-to-clipboard.ts');
return runBunScript(copyScript, ['image', imagePath]);
}
export function copyHtmlToClipboard(htmlPath: string): boolean {
const copyScript = path.join(getScriptDir(), 'copy-to-clipboard.ts');
return runBunScript(copyScript, ['html', '--file', htmlPath]);
}
export function pasteFromClipboard(targetApp?: string, retries = 3, delayMs = 500): boolean {
const pasteScript = path.join(getScriptDir(), 'paste-from-clipboard.ts');
const args = ['--retries', String(retries), '--delay', String(delayMs)];
if (targetApp) args.push('--app', targetApp);
return runBunScript(pasteScript, args);
}
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,
CdpConnection,
findChromeExecutable,
getDefaultProfileDir,
getFreePort,
sleep,
waitForChromeDebugPort,
} from './x-utils.js';
const X_COMPOSE_URL = 'https://x.com/compose/post';
interface XVideoOptions {
text?: string;
videoPath: string;
submit?: boolean;
timeoutMs?: number;
profileDir?: string;
chromePath?: string;
}
export async function postVideoToX(options: XVideoOptions): Promise<void> {
const { text, videoPath, submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;
const chromePath = options.chromePath ?? findChromeExecutable(CHROME_CANDIDATES_FULL);
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, { includeLastError: true });
cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 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<boolean> => {
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, uploading in background...');
// Wait a moment for upload to start, then type text while video processes
await sleep(2000);
// Type text while video uploads in background
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);
}
// Wait for video to finish processing by checking if tweet button is enabled
console.log('[x-video] Waiting for video processing...');
const waitForVideoReady = async (maxWaitMs = 180_000): Promise<boolean> => {
const start = Date.now();
let dots = 0;
while (Date.now() - start < maxWaitMs) {
const result = await cdp!.send<{ result: { value: { hasMedia: boolean; buttonEnabled: boolean } } }>('Runtime.evaluate', {
expression: `(() => {
const hasMedia = !!document.querySelector('[data-testid="attachments"] video, [data-testid="videoPlayer"], video');
const tweetBtn = document.querySelector('[data-testid="tweetButton"]');
const buttonEnabled = tweetBtn && !tweetBtn.disabled && tweetBtn.getAttribute('aria-disabled') !== 'true';
return { hasMedia, buttonEnabled };
})()`,
returnByValue: true,
}, { sessionId });
const { hasMedia, buttonEnabled } = result.result.value;
if (hasMedia && buttonEnabled) {
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.');
}
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 <path> [text]
Options:
--video <path> Video file path (required, supports mp4/mov/webm)
--submit Actually post (default: preview only)
--profile <dir> 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<void> {
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 <path> 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);
});