mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-08-08 01:43:03 +08:00
feat: add baoyu- prefix
This commit is contained in:
@@ -0,0 +1,720 @@
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
import { parseMarkdown } from './md-to-html.js';
|
||||
|
||||
const X_ARTICLES_URL = 'https://x.com/compose/articles';
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
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 port')));
|
||||
return;
|
||||
}
|
||||
server.close((err) => (err ? reject(err) : resolve(address.port)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function findChromeExecutable(): string | undefined {
|
||||
const override = process.env.X_BROWSER_CHROME_PATH?.trim();
|
||||
if (override && fs.existsSync(override)) return override;
|
||||
|
||||
const candidates: string[] = [];
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
candidates.push(
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
);
|
||||
break;
|
||||
case 'win32':
|
||||
candidates.push(
|
||||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||||
);
|
||||
break;
|
||||
default:
|
||||
candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser');
|
||||
break;
|
||||
}
|
||||
|
||||
for (const p of candidates) if (fs.existsSync(p)) return p;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getDefaultProfileDir(): string {
|
||||
const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
||||
return path.join(base, 'x-browser-profile');
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const res = await fetch(url, { redirect: 'follow' });
|
||||
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise<string> {
|
||||
const start = Date.now();
|
||||
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;
|
||||
} catch {}
|
||||
await sleep(200);
|
||||
}
|
||||
throw new Error('Chrome debug port not ready');
|
||||
}
|
||||
|
||||
class CdpConnection {
|
||||
private ws: WebSocket;
|
||||
private nextId = 0;
|
||||
private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> | null }>();
|
||||
|
||||
private constructor(ws: WebSocket) {
|
||||
this.ws = ws;
|
||||
this.ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer);
|
||||
const msg = JSON.parse(data) as { id?: number; result?: unknown; error?: { message?: string } };
|
||||
if (msg.id) {
|
||||
const pending = this.pending.get(msg.id);
|
||||
if (pending) {
|
||||
this.pending.delete(msg.id);
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
if (msg.error?.message) pending.reject(new Error(msg.error.message));
|
||||
else pending.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
this.ws.addEventListener('close', () => {
|
||||
for (const [id, pending] of this.pending.entries()) {
|
||||
this.pending.delete(id);
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
pending.reject(new Error('CDP connection closed'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(url: string, timeoutMs: number): Promise<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);
|
||||
}
|
||||
|
||||
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 ?? 30_000;
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) : null;
|
||||
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, timer });
|
||||
this.ws.send(JSON.stringify(message));
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try { this.ws.close(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function copyImageToClipboard(imagePath: string): boolean {
|
||||
const scriptDir = path.dirname(new URL(import.meta.url).pathname);
|
||||
const copyScript = path.join(scriptDir, 'copy-to-clipboard.ts');
|
||||
const result = spawnSync('npx', ['-y', 'bun', copyScript, 'image', imagePath], { stdio: 'inherit' });
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
function copyHtmlToClipboard(htmlPath: string): boolean {
|
||||
const scriptDir = path.dirname(new URL(import.meta.url).pathname);
|
||||
const copyScript = path.join(scriptDir, 'copy-to-clipboard.ts');
|
||||
const result = spawnSync('npx', ['-y', 'bun', copyScript, 'html', '--file', htmlPath], { stdio: 'inherit' });
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
function sendRealPasteKeystroke(retries = 3): boolean {
|
||||
if (process.platform !== 'darwin') {
|
||||
console.log('[x-article] Real keystroke only supported on macOS');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use osascript to send Cmd+V to frontmost application (Chrome)
|
||||
const script = `
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
end tell
|
||||
`;
|
||||
|
||||
for (let i = 0; i < retries; i++) {
|
||||
const result = spawnSync('osascript', ['-e', script], { stdio: 'pipe' });
|
||||
if (result.status === 0) {
|
||||
return true;
|
||||
}
|
||||
// Wait a bit before retry
|
||||
if (i < retries - 1) {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface ArticleOptions {
|
||||
markdownPath: string;
|
||||
coverImage?: string;
|
||||
title?: string;
|
||||
submit?: boolean;
|
||||
profileDir?: string;
|
||||
chromePath?: string;
|
||||
}
|
||||
|
||||
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();
|
||||
if (!chromePath) throw new Error('Chrome not found');
|
||||
|
||||
await mkdir(profileDir, { recursive: true });
|
||||
const port = await getFreePort();
|
||||
|
||||
console.log(`[x-article] Launching Chrome...`);
|
||||
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_ARTICLES_URL,
|
||||
], { stdio: 'ignore' });
|
||||
|
||||
let cdp: CdpConnection | null = null;
|
||||
|
||||
try {
|
||||
const wsUrl = await waitForChromeDebugPort(port, 30_000);
|
||||
cdp = await CdpConnection.connect(wsUrl, 30_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.includes('x.com'));
|
||||
|
||||
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(3000);
|
||||
|
||||
// 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 });
|
||||
};
|
||||
|
||||
const pasteFromClipboard = async (): Promise<void> => {
|
||||
const modifiers = process.platform === 'darwin' ? 4 : 2; // Meta or Ctrl
|
||||
await pressKey('v', modifiers);
|
||||
};
|
||||
|
||||
// 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)
|
||||
console.log('[x-article] Waiting for editor...');
|
||||
const editorFound = await waitForElement('textarea[placeholder="Add a title"], textarea[name="Article Title"]', 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
|
||||
await cdp.send('Runtime.evaluate', {
|
||||
expression: `document.querySelector('[aria-label="Add photos or video"]')?.click()`,
|
||||
}, { 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) {
|
||||
await cdp.send('Runtime.evaluate', {
|
||||
expression: `document.querySelector('[data-testid="applyButton"]')?.click()`,
|
||||
}, { sessionId });
|
||||
console.log('[x-article] Cover image applied');
|
||||
await sleep(1000);
|
||||
} 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
|
||||
await cdp.send('Runtime.evaluate', {
|
||||
expression: `document.querySelector('textarea[placeholder="Add a title"], textarea[name="Article Title"]')?.focus()`,
|
||||
}, { 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) {
|
||||
if (editorContent.result.value.includes(img.placeholder)) {
|
||||
console.log(`[x-article] Found: ${img.placeholder}`);
|
||||
} else {
|
||||
console.log(`[x-article] NOT found: ${img.placeholder}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Process images in sequential order (1, 2, 3, ...)
|
||||
const sortedImages = [...parsed.contentImages].sort((a, b) => a.blockIndex - b.blockIndex);
|
||||
|
||||
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}`);
|
||||
|
||||
// Find, scroll to, and select the placeholder text in DraftEditor
|
||||
const placeholderFound = await cdp.send<{ result: { value: boolean } }>('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 || '';
|
||||
const idx = text.indexOf(placeholder);
|
||||
if (idx !== -1) {
|
||||
// Found the 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;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})()`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
|
||||
// Wait for scroll animation
|
||||
await sleep(300);
|
||||
|
||||
if (!placeholderFound.result.value) {
|
||||
console.warn(`[x-article] Placeholder not found in DOM: ${img.placeholder}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[x-article] Placeholder selected, 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;
|
||||
}
|
||||
|
||||
await sleep(300);
|
||||
|
||||
// Delete placeholder by pressing Enter (placeholder is already selected)
|
||||
console.log(`[x-article] Deleting placeholder with Enter...`);
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId });
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId });
|
||||
await sleep(200);
|
||||
|
||||
// Paste image
|
||||
console.log(`[x-article] Pasting image...`);
|
||||
if (sendRealPasteKeystroke()) {
|
||||
console.log(`[x-article] Image pasted: ${path.basename(img.localPath)}`);
|
||||
} else {
|
||||
console.warn(`[x-article] Failed to paste image`);
|
||||
}
|
||||
|
||||
// Wait for image to upload
|
||||
console.log(`[x-article] Waiting for upload...`);
|
||||
await sleep(4000);
|
||||
}
|
||||
|
||||
console.log('[x-article] All images processed.');
|
||||
}
|
||||
|
||||
// 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 previewClicked = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
// Try multiple selectors for preview button
|
||||
const previewLink = document.querySelector('a[href*="/preview"]')
|
||||
|| document.querySelector('[data-testid="previewButton"]')
|
||||
|| document.querySelector('button[aria-label*="preview" i]');
|
||||
if (previewLink) {
|
||||
previewLink.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...');
|
||||
await cdp.send('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
const publishBtn = document.querySelector('[data-testid="publishButton"], button[aria-label*="publish" i], button[aria-label*="发布" i]');
|
||||
if (publishBtn && !publishBtn.disabled) {
|
||||
publishBtn.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 will stay open for 60 seconds for review...');
|
||||
await sleep(60_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(`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]) {
|
||||
coverImage = args[++i];
|
||||
} 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);
|
||||
});
|
||||
Reference in New Issue
Block a user