Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Post text, images, videos, and long-form articles to X (Twitter) via real Chrome browser automation.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/x-article.ts
1import fs from 'node:fs';2import { mkdir, writeFile } from 'node:fs/promises';3import os from 'node:os';4import path from 'node:path';56import { parseMarkdown } from './md-to-html.js';7import {8CHROME_CANDIDATES_BASIC,9CdpConnection,10copyHtmlToClipboard,11copyImageToClipboard,12findExistingChromeDebugPort,13getDefaultProfileDir,14launchChrome,15openPageSession,16pasteFromClipboard,17sleep,18waitForChromeDebugPort,19} from './x-utils.js';2021const X_ARTICLES_URL = 'https://x.com/compose/articles';2223const I18N_SELECTORS = {24titleInput: [25'textarea[placeholder="Add a title"]',26'textarea[placeholder="添加标题"]',27'textarea[placeholder="タイトルを追加"]',28'textarea[placeholder="제목 추가"]',29'textarea[name="Article Title"]',30],31addPhotosButton: [32'[aria-label="Add photos or video"]',33'[aria-label="添加照片或视频"]',34'[aria-label="写真や動画を追加"]',35'[aria-label="사진 또는 동영상 추가"]',36],37previewButton: [38'a[href*="/preview"]',39'[data-testid="previewButton"]',40'button[aria-label*="preview" i]',41'button[aria-label*="预览" i]',42'button[aria-label*="プレビュー" i]',43'button[aria-label*="미리보기" i]',44],45publishButton: [46'[data-testid="publishButton"]',47'button[aria-label*="publish" i]',48'button[aria-label*="发布" i]',49'button[aria-label*="公開" i]',50'button[aria-label*="게시" i]',51],52};5354interface ArticleOptions {55markdownPath: string;56coverImage?: string;57title?: string;58submit?: boolean;59profileDir?: string;60chromePath?: string;61}6263export async function publishArticle(options: ArticleOptions): Promise<void> {64const { markdownPath, submit = false, profileDir = getDefaultProfileDir() } = options;6566console.log('[x-article] Parsing markdown...');67const parsed = await parseMarkdown(markdownPath, {68title: options.title,69coverImage: options.coverImage,70});7172console.log(`[x-article] Title: ${parsed.title}`);73console.log(`[x-article] Cover: ${parsed.coverImage ?? 'none'}`);74console.log(`[x-article] Content images: ${parsed.contentImages.length}`);7576// Save HTML to temp file77const htmlPath = path.join(os.tmpdir(), 'x-article-content.html');78await writeFile(htmlPath, parsed.html, 'utf-8');79console.log(`[x-article] HTML saved to: ${htmlPath}`);8081await mkdir(profileDir, { recursive: true });82const existingPort = await findExistingChromeDebugPort(profileDir);83const reusing = existingPort !== null;84let port = existingPort ?? 0;8586if (reusing) {87console.log(`[x-article] Reusing existing Chrome instance on port ${port}`);88} else {89console.log(`[x-article] Launching Chrome...`);90const launched = await launchChrome(X_ARTICLES_URL, profileDir, CHROME_CANDIDATES_BASIC, options.chromePath);91port = launched.port;92}9394let cdp: CdpConnection | null = null;9596try {97const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });98cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 60_000 });99100const page = await openPageSession({101cdp,102reusing,103url: X_ARTICLES_URL,104matchTarget: (target) => target.type === 'page' && target.url.startsWith(X_ARTICLES_URL),105enablePage: true,106enableRuntime: true,107enableDom: true,108});109const { sessionId } = page;110111console.log('[x-article] Waiting for articles page...');112await sleep(1000);113114// Wait for and click "create" button115const waitForElement = async (selector: string, timeoutMs = 60_000): Promise<boolean> => {116const start = Date.now();117while (Date.now() - start < timeoutMs) {118const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {119expression: `!!document.querySelector('${selector}')`,120returnByValue: true,121}, { sessionId });122if (result.result.value) return true;123await sleep(500);124}125return false;126};127128const clickElement = async (selector: string): Promise<boolean> => {129const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {130expression: `(() => { const el = document.querySelector('${selector}'); if (el) { el.click(); return true; } return false; })()`,131returnByValue: true,132}, { sessionId });133return result.result.value;134};135136const typeText = async (selector: string, text: string): Promise<void> => {137await cdp!.send('Runtime.evaluate', {138expression: `(() => {139const el = document.querySelector('${selector}');140if (el) {141el.focus();142document.execCommand('insertText', false, ${JSON.stringify(text)});143}144})()`,145}, { sessionId });146};147148const pressKey = async (key: string, modifiers = 0): Promise<void> => {149await cdp!.send('Input.dispatchKeyEvent', {150type: 'keyDown',151key,152code: `Key${key.toUpperCase()}`,153modifiers,154windowsVirtualKeyCode: key.toUpperCase().charCodeAt(0),155}, { sessionId });156await cdp!.send('Input.dispatchKeyEvent', {157type: 'keyUp',158key,159code: `Key${key.toUpperCase()}`,160modifiers,161windowsVirtualKeyCode: key.toUpperCase().charCodeAt(0),162}, { sessionId });163};164165// Check if we're on the articles list page (has Write button)166console.log('[x-article] Looking for Write button...');167const writeButtonFound = await waitForElement('[data-testid="empty_state_button_text"]', 10_000);168169if (writeButtonFound) {170console.log('[x-article] Clicking Write button...');171await cdp.send('Runtime.evaluate', {172expression: `document.querySelector('[data-testid="empty_state_button_text"]')?.click()`,173}, { sessionId });174await sleep(2000);175}176177// Wait for editor (title textarea)178const titleSelectors = I18N_SELECTORS.titleInput.join(', ');179console.log('[x-article] Waiting for editor...');180const editorFound = await waitForElement(titleSelectors, 30_000);181if (!editorFound) {182console.log('[x-article] Editor not found. Please ensure you have X Premium and are logged in.');183await sleep(60_000);184throw new Error('Editor not found');185}186187// Upload cover image188if (parsed.coverImage) {189console.log('[x-article] Uploading cover image...');190191// Click "Add photos or video" button192const addPhotosSelectors = JSON.stringify(I18N_SELECTORS.addPhotosButton);193await cdp.send('Runtime.evaluate', {194expression: `(() => {195const selectors = ${addPhotosSelectors};196for (const sel of selectors) {197const el = document.querySelector(sel);198if (el) { el.click(); return true; }199}200return false;201})()`,202}, { sessionId });203await sleep(500);204205// Use file input directly206const { root } = await cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {}, { sessionId });207const { nodeId } = await cdp.send<{ nodeId: number }>('DOM.querySelector', {208nodeId: root.nodeId,209selector: '[data-testid="fileInput"], input[type="file"][accept*="image"]',210}, { sessionId });211212if (nodeId) {213await cdp.send('DOM.setFileInputFiles', {214nodeId,215files: [parsed.coverImage],216}, { sessionId });217console.log('[x-article] Cover image file set');218219// Wait for Apply button to appear and click it220console.log('[x-article] Waiting for Apply button...');221const applyFound = await waitForElement('[data-testid="applyButton"]', 15_000);222if (applyFound) {223// Check if modal is present224const isModalOpen = async (): Promise<boolean> => {225const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {226expression: `!!document.querySelector('[role="dialog"][aria-modal="true"]')`,227returnByValue: true,228}, { sessionId });229return result.result.value;230};231232// Click Apply button with retry logic233const maxRetries = 3;234for (let attempt = 1; attempt <= maxRetries; attempt++) {235console.log(`[x-article] Clicking Apply button (attempt ${attempt}/${maxRetries})...`);236237await cdp.send('Runtime.evaluate', {238expression: `document.querySelector('[data-testid="applyButton"]')?.click()`,239}, { sessionId });240241// Wait for modal to close (up to 5 seconds per attempt)242const closeTimeout = 5000;243const checkInterval = 300;244const startTime = Date.now();245let modalClosed = false;246247while (Date.now() - startTime < closeTimeout) {248await sleep(checkInterval);249const stillOpen = await isModalOpen();250if (!stillOpen) {251modalClosed = true;252break;253}254}255256if (modalClosed) {257console.log('[x-article] Cover image applied, modal closed');258await sleep(500);259break;260}261262if (attempt < maxRetries) {263console.log('[x-article] Modal still open, retrying...');264} else {265console.log('[x-article] Modal did not close after all attempts, continuing anyway...');266}267}268} else {269console.log('[x-article] Apply button not found, continuing...');270}271}272}273274// Fill title using keyboard input275if (parsed.title) {276console.log('[x-article] Filling title...');277278// Focus title input279const titleInputSelectors = JSON.stringify(I18N_SELECTORS.titleInput);280await cdp.send('Runtime.evaluate', {281expression: `(() => {282const selectors = ${titleInputSelectors};283for (const sel of selectors) {284const el = document.querySelector(sel);285if (el) { el.focus(); return true; }286}287return false;288})()`,289}, { sessionId });290await sleep(200);291292// Type title character by character using insertText293await cdp.send('Input.insertText', { text: parsed.title }, { sessionId });294await sleep(300);295296// Tab out to trigger save297await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 }, { sessionId });298await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 }, { sessionId });299await sleep(500);300}301302// Insert HTML content303console.log('[x-article] Inserting content...');304305// Read HTML content306const htmlContent = fs.readFileSync(htmlPath, 'utf-8');307308// Focus on DraftEditor body309await cdp.send('Runtime.evaluate', {310expression: `(() => {311const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');312if (editor) {313editor.focus();314editor.click();315return true;316}317return false;318})()`,319}, { sessionId });320await sleep(300);321322// Method 1: Simulate paste event with HTML data323console.log('[x-article] Attempting to insert HTML via paste event...');324const pasteResult = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {325expression: `(() => {326const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');327if (!editor) return false;328329const html = ${JSON.stringify(htmlContent)};330331// Create a paste event with HTML data332const dt = new DataTransfer();333dt.setData('text/html', html);334dt.setData('text/plain', html.replace(/<[^>]*>/g, ''));335336const pasteEvent = new ClipboardEvent('paste', {337bubbles: true,338cancelable: true,339clipboardData: dt340});341342editor.dispatchEvent(pasteEvent);343return true;344})()`,345returnByValue: true,346}, { sessionId });347348await sleep(1000);349350// Check if content was inserted351const contentCheck = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {352expression: `document.querySelector('.DraftEditor-editorContainer [data-contents="true"]')?.innerText?.length || 0`,353returnByValue: true,354}, { sessionId });355356if (contentCheck.result.value > 50) {357console.log(`[x-article] Content inserted successfully (${contentCheck.result.value} chars)`);358} else {359console.log('[x-article] Paste event may not have worked, trying insertHTML...');360361// Method 2: Use execCommand insertHTML362await cdp.send('Runtime.evaluate', {363expression: `(() => {364const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');365if (!editor) return false;366editor.focus();367document.execCommand('insertHTML', false, ${JSON.stringify(htmlContent)});368return true;369})()`,370}, { sessionId });371372await sleep(1000);373374// Check again375const check2 = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {376expression: `document.querySelector('.DraftEditor-editorContainer [data-contents="true"]')?.innerText?.length || 0`,377returnByValue: true,378}, { sessionId });379380if (check2.result.value > 50) {381console.log(`[x-article] Content inserted via execCommand (${check2.result.value} chars)`);382} else {383console.log('[x-article] Auto-insert failed. HTML copied to clipboard - please paste manually (Cmd+V)');384copyHtmlToClipboard(htmlPath);385// Wait for manual paste386console.log('[x-article] Waiting 30s for manual paste...');387await sleep(30_000);388}389}390391// Insert content images (reverse order to maintain positions)392if (parsed.contentImages.length > 0) {393console.log('[x-article] Inserting content images...');394395// First, check what placeholders exist in the editor396const editorContent = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {397expression: `document.querySelector('.DraftEditor-editorContainer [data-contents="true"]')?.innerText || ''`,398returnByValue: true,399}, { sessionId });400401console.log('[x-article] Checking for placeholders in content...');402for (const img of parsed.contentImages) {403// Use regex for exact match (not followed by digit, e.g., XIMGPH_1 should not match XIMGPH_10)404const regex = new RegExp(img.placeholder + '(?!\\d)');405if (regex.test(editorContent.result.value)) {406console.log(`[x-article] Found: ${img.placeholder}`);407} else {408console.log(`[x-article] NOT found: ${img.placeholder}`);409}410}411412// Process images in XIMGPH order (1, 2, 3, ...) regardless of blockIndex413const getPlaceholderIndex = (placeholder: string): number => {414const match = placeholder.match(/XIMGPH_(\d+)/);415return match ? Number(match[1]) : Number.POSITIVE_INFINITY;416};417const sortedImages = [...parsed.contentImages].sort(418(a, b) => getPlaceholderIndex(a.placeholder) - getPlaceholderIndex(b.placeholder),419);420421for (let i = 0; i < sortedImages.length; i++) {422const img = sortedImages[i]!;423console.log(`[x-article] [${i + 1}/${sortedImages.length}] Inserting image at placeholder: ${img.placeholder}`);424425// Helper to select placeholder with retry426const selectPlaceholder = async (maxRetries = 3): Promise<boolean> => {427for (let attempt = 1; attempt <= maxRetries; attempt++) {428// Find, scroll to, and select the placeholder text in DraftEditor429await cdp!.send('Runtime.evaluate', {430expression: `(() => {431const editor = document.querySelector('.DraftEditor-editorContainer [data-contents="true"]');432if (!editor) return false;433434const placeholder = ${JSON.stringify(img.placeholder)};435436// Search through all text nodes in the editor437const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false);438let node;439440while ((node = walker.nextNode())) {441const text = node.textContent || '';442let searchStart = 0;443let idx;444// Search for exact match (not prefix of longer placeholder like XIMGPH_1 in XIMGPH_10)445while ((idx = text.indexOf(placeholder, searchStart)) !== -1) {446const afterIdx = idx + placeholder.length;447const charAfter = text[afterIdx];448// Exact match if next char is not a digit (XIMGPH_1 should not match XIMGPH_10)449if (charAfter === undefined || !/\\d/.test(charAfter)) {450// Found exact placeholder - scroll to it first451const parentElement = node.parentElement;452if (parentElement) {453parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });454}455456// Select it457const range = document.createRange();458range.setStart(node, idx);459range.setEnd(node, idx + placeholder.length);460const sel = window.getSelection();461sel.removeAllRanges();462sel.addRange(range);463return true;464}465searchStart = afterIdx;466}467}468return false;469})()`,470}, { sessionId });471472// Wait for scroll and selection to settle473await sleep(800);474475// Verify selection matches the placeholder476const selectionCheck = await cdp!.send<{ result: { value: string } }>('Runtime.evaluate', {477expression: `window.getSelection()?.toString() || ''`,478returnByValue: true,479}, { sessionId });480481const selectedText = selectionCheck.result.value.trim();482if (selectedText === img.placeholder) {483console.log(`[x-article] Selection verified: "${selectedText}"`);484return true;485}486487if (attempt < maxRetries) {488console.log(`[x-article] Selection attempt ${attempt} got "${selectedText}", retrying...`);489await sleep(500);490} else {491console.warn(`[x-article] Selection failed after ${maxRetries} attempts, got: "${selectedText}"`);492}493}494return false;495};496497// Try to select the placeholder498const selected = await selectPlaceholder(3);499if (!selected) {500console.warn(`[x-article] Skipping image - could not select placeholder: ${img.placeholder}`);501continue;502}503504console.log(`[x-article] Copying image: ${path.basename(img.localPath)}`);505506// Copy image to clipboard507if (!copyImageToClipboard(img.localPath)) {508console.warn(`[x-article] Failed to copy image to clipboard`);509continue;510}511512// Wait for clipboard to be fully ready513await sleep(1000);514515// Delete placeholder using execCommand (more reliable than keyboard events for DraftJS)516console.log(`[x-article] Deleting placeholder...`);517const deleteResult = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {518expression: `(() => {519const sel = window.getSelection();520if (!sel || sel.isCollapsed) return false;521// Try execCommand delete first522if (document.execCommand('delete', false)) return true;523// Fallback: replace selection with empty using insertText524document.execCommand('insertText', false, '');525return true;526})()`,527returnByValue: true,528}, { sessionId });529530await sleep(500);531532// Check that placeholder is no longer in editor (exact match, not substring)533const afterDelete = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {534expression: `(() => {535const editor = document.querySelector('.DraftEditor-editorContainer [data-contents="true"]');536if (!editor) return true;537const text = editor.innerText;538const placeholder = ${JSON.stringify(img.placeholder)};539// Use regex to find exact match (not followed by digit)540const regex = new RegExp(placeholder + '(?!\\\\d)');541return !regex.test(text);542})()`,543returnByValue: true,544}, { sessionId });545546if (!afterDelete.result.value) {547console.warn(`[x-article] Placeholder may not have been deleted, trying dispatchEvent...`);548// Try selecting and deleting with InputEvent549await selectPlaceholder(1);550await sleep(300);551await cdp.send('Runtime.evaluate', {552expression: `(() => {553const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');554if (!editor) return;555editor.focus();556// Dispatch beforeinput and input events for deletion557const beforeEvent = new InputEvent('beforeinput', { inputType: 'deleteContentBackward', bubbles: true, cancelable: true });558editor.dispatchEvent(beforeEvent);559const inputEvent = new InputEvent('input', { inputType: 'deleteContentBackward', bubbles: true });560editor.dispatchEvent(inputEvent);561})()`,562}, { sessionId });563await sleep(500);564}565566// Count existing image blocks before paste567const imgCountBefore = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {568expression: `document.querySelectorAll('section[data-block="true"][contenteditable="false"] img[src^="blob:"]').length`,569returnByValue: true,570}, { sessionId });571572// Focus editor to ensure cursor is in position573await cdp.send('Runtime.evaluate', {574expression: `(() => {575const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');576if (editor) editor.focus();577})()`,578}, { sessionId });579await sleep(300);580581// Paste image using paste script (activates Chrome, sends real keystroke)582console.log(`[x-article] Pasting image...`);583if (pasteFromClipboard('Google Chrome', 5, 1000)) {584console.log(`[x-article] Image pasted: ${path.basename(img.localPath)}`);585} else {586console.warn(`[x-article] Failed to paste image after retries`);587}588589// Verify image appeared in editor590console.log(`[x-article] Verifying image upload...`);591const expectedImgCount = imgCountBefore.result.value + 1;592let imgUploadOk = false;593const imgWaitStart = Date.now();594while (Date.now() - imgWaitStart < 15_000) {595const r = await cdp!.send<{ result: { value: number } }>('Runtime.evaluate', {596expression: `document.querySelectorAll('section[data-block="true"][contenteditable="false"] img[src^="blob:"]').length`,597returnByValue: true,598}, { sessionId });599if (r.result.value >= expectedImgCount) {600imgUploadOk = true;601break;602}603await sleep(1000);604}605606if (imgUploadOk) {607console.log(`[x-article] Image upload verified (${expectedImgCount} image block(s))`);608// Wait for DraftEditor DOM to stabilize after image insertion609await sleep(3000);610} else {611console.warn(`[x-article] Image upload not detected after 15s`);612if (i === 0) {613console.error('[x-article] First image paste failed. Run check-paste-permissions.ts to diagnose.');614}615}616}617618console.log('[x-article] All images processed.');619620// Final verification: check placeholder residue and image count621console.log('[x-article] Running post-composition verification...');622const finalEditorContent = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {623expression: `document.querySelector('.DraftEditor-editorContainer [data-contents="true"]')?.innerText || ''`,624returnByValue: true,625}, { sessionId });626627const remainingPlaceholders: string[] = [];628for (const img of parsed.contentImages) {629const regex = new RegExp(img.placeholder + '(?!\\d)');630if (regex.test(finalEditorContent.result.value)) {631remainingPlaceholders.push(img.placeholder);632}633}634635const finalImgCount = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {636expression: `document.querySelectorAll('section[data-block="true"][contenteditable="false"] img[src^="blob:"]').length`,637returnByValue: true,638}, { sessionId });639640const expectedCount = parsed.contentImages.length;641const actualCount = finalImgCount.result.value;642643if (remainingPlaceholders.length > 0 || actualCount < expectedCount) {644console.warn('[x-article] ⚠ POST-COMPOSITION CHECK FAILED:');645if (remainingPlaceholders.length > 0) {646console.warn(`[x-article] Remaining placeholders: ${remainingPlaceholders.join(', ')}`);647}648if (actualCount < expectedCount) {649console.warn(`[x-article] Image count: expected ${expectedCount}, found ${actualCount}`);650}651console.warn('[x-article] Please check the article before publishing.');652} else {653console.log(`[x-article] ✓ Verification passed: ${actualCount} image(s), no remaining placeholders.`);654}655}656657// Before preview: blur editor to trigger save658console.log('[x-article] Triggering content save...');659await cdp.send('Runtime.evaluate', {660expression: `(() => {661// Blur editor to trigger any pending saves662const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');663if (editor) {664editor.blur();665}666// Also click elsewhere to ensure focus is lost667document.body.click();668})()`,669}, { sessionId });670await sleep(1500);671672// Click Preview button673console.log('[x-article] Opening preview...');674const previewSelectors = JSON.stringify(I18N_SELECTORS.previewButton);675const previewClicked = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {676expression: `(() => {677const selectors = ${previewSelectors};678for (const sel of selectors) {679const el = document.querySelector(sel);680if (el) { el.click(); return true; }681}682return false;683})()`,684returnByValue: true,685}, { sessionId });686687if (previewClicked.result.value) {688console.log('[x-article] Preview opened');689await sleep(3000);690} else {691console.log('[x-article] Preview button not found');692}693694// Check for publish button695if (submit) {696console.log('[x-article] Publishing...');697const publishSelectors = JSON.stringify(I18N_SELECTORS.publishButton);698await cdp.send('Runtime.evaluate', {699expression: `(() => {700const selectors = ${publishSelectors};701for (const sel of selectors) {702const el = document.querySelector(sel);703if (el && !el.disabled) { el.click(); return true; }704}705return false;706})()`,707}, { sessionId });708await sleep(3000);709console.log('[x-article] Article published!');710} else {711console.log('[x-article] Article composed (draft mode).');712console.log('[x-article] Browser remains open for manual review.');713}714715} finally {716// Disconnect CDP but keep browser open717if (cdp) {718cdp.close();719}720// Don't kill Chrome - let user review and close manually721}722}723724function printUsage(): never {725console.log(`Publish Markdown article to X (Twitter) Articles726727Usage:728npx -y bun x-article.ts <markdown_file> [options]729730Options:731--title <title> Override title732--cover <image> Override cover image733--submit Actually publish (default: draft only)734--profile <dir> Chrome profile directory735--help Show this help736737Markdown frontmatter:738---739title: My Article Title740cover_image: /path/to/cover.jpg741---742743Example:744npx -y bun x-article.ts article.md745npx -y bun x-article.ts article.md --cover ./hero.png746npx -y bun x-article.ts article.md --submit747`);748process.exit(0);749}750751async function main(): Promise<void> {752const args = process.argv.slice(2);753if (args.length === 0 || args.includes('--help') || args.includes('-h')) {754printUsage();755}756757let markdownPath: string | undefined;758let title: string | undefined;759let coverImage: string | undefined;760let submit = false;761let profileDir: string | undefined;762763for (let i = 0; i < args.length; i++) {764const arg = args[i]!;765if (arg === '--title' && args[i + 1]) {766title = args[++i];767} else if (arg === '--cover' && args[i + 1]) {768const raw = args[++i]!;769coverImage = path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);770} else if (arg === '--submit') {771submit = true;772} else if (arg === '--profile' && args[i + 1]) {773profileDir = args[++i];774} else if (!arg.startsWith('-')) {775markdownPath = arg;776}777}778779if (!markdownPath) {780console.error('Error: Markdown file path required');781process.exit(1);782}783784if (!fs.existsSync(markdownPath)) {785console.error(`Error: File not found: ${markdownPath}`);786process.exit(1);787}788789await publishArticle({ markdownPath, title, coverImage, submit, profileDir });790}791792await main().catch((err) => {793console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);794process.exit(1);795});796