From 235868343c9cd3514cf67515daa36fe9fc903295 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Liu=20=E5=AE=9D=E7=8E=89?= Date: Wed, 21 Jan 2026 11:28:41 -0600 Subject: [PATCH] chore: release v1.12.0 --- .claude-plugin/marketplace.json | 2 +- CHANGELOG.md | 5 + CHANGELOG.zh.md | 5 + skills/baoyu-post-to-x/scripts/x-article.ts | 176 ++------------ skills/baoyu-post-to-x/scripts/x-browser.ts | 212 ++--------------- skills/baoyu-post-to-x/scripts/x-quote.ts | 184 +-------------- skills/baoyu-post-to-x/scripts/x-utils.ts | 242 ++++++++++++++++++++ skills/baoyu-post-to-x/scripts/x-video.ts | 176 +------------- 8 files changed, 307 insertions(+), 695 deletions(-) create mode 100644 skills/baoyu-post-to-x/scripts/x-utils.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c3faa4a..e0f7a99 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "Skills shared by Baoyu for improving daily work efficiency", - "version": "1.11.0" + "version": "1.12.0" }, "plugins": [ { diff --git a/CHANGELOG.md b/CHANGELOG.md index 927b8b4..6b4784a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ English | [中文](./CHANGELOG.zh.md) +## 1.12.0 - 2026-01-21 + +### Refactor +- `baoyu-post-to-x`: extracts shared utilities to `x-utils.ts`—consolidates Chrome detection, CDP connection, clipboard operations, and helper functions from `x-article.ts`, `x-browser.ts`, `x-quote.ts`, and `x-video.ts` into a single reusable module. + ## 1.11.0 - 2026-01-21 ### Features diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index 088642a..d9e5152 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -2,6 +2,11 @@ [English](./CHANGELOG.md) | 中文 +## 1.12.0 - 2026-01-21 + +### 重构 +- `baoyu-post-to-x`:提取公共工具函数到 `x-utils.ts`——将 `x-article.ts`、`x-browser.ts`、`x-quote.ts`、`x-video.ts` 中重复的 Chrome 检测、CDP 连接、剪贴板操作等功能整合为统一的可复用模块。 + ## 1.11.0 - 2026-01-21 ### 新功能 diff --git a/skills/baoyu-post-to-x/scripts/x-article.ts b/skills/baoyu-post-to-x/scripts/x-article.ts index 301ef8f..9b31a1a 100644 --- a/skills/baoyu-post-to-x/scripts/x-article.ts +++ b/skills/baoyu-post-to-x/scripts/x-article.ts @@ -1,12 +1,23 @@ -import { spawn, spawnSync } from 'node:child_process'; +import { spawn } 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'; +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'; @@ -41,163 +52,6 @@ const I18N_SELECTORS = { ], }; -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function getFreePort(): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on('error', reject); - server.listen(0, '127.0.0.1', () => { - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(() => reject(new Error('Unable to allocate 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(url: string): Promise { - const res = await fetch(url, { redirect: 'follow' }); - if (!res.ok) throw new Error(`Request failed: ${res.status}`); - return res.json() as Promise; -} - -async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise { - 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 void; reject: (e: Error) => void; timer: ReturnType | null }>(); - - private constructor(ws: WebSocket) { - this.ws = ws; - this.ws.addEventListener('message', (event) => { - try { - const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer); - const msg = JSON.parse(data) as { id?: number; result?: unknown; error?: { message?: string } }; - if (msg.id) { - const pending = this.pending.get(msg.id); - if (pending) { - this.pending.delete(msg.id); - if (pending.timer) clearTimeout(pending.timer); - if (msg.error?.message) pending.reject(new Error(msg.error.message)); - else pending.resolve(msg.result); - } - } - } catch {} - }); - this.ws.addEventListener('close', () => { - for (const [id, pending] of this.pending.entries()) { - this.pending.delete(id); - if (pending.timer) clearTimeout(pending.timer); - pending.reject(new Error('CDP connection closed')); - } - }); - } - - static async connect(url: string, timeoutMs: number): Promise { - const ws = new WebSocket(url); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('CDP connection timeout')), timeoutMs); - ws.addEventListener('open', () => { clearTimeout(timer); resolve(); }); - ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('CDP connection failed')); }); - }); - return new CdpConnection(ws); - } - - async send(method: string, params?: Record, options?: { sessionId?: string; timeoutMs?: number }): Promise { - const id = ++this.nextId; - const message: Record = { id, method }; - if (params) message.params = params; - if (options?.sessionId) message.sessionId = options.sessionId; - const timeoutMs = options?.timeoutMs ?? 30_000; - - return new Promise((resolve, reject) => { - const timer = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) : null; - this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, timer }); - this.ws.send(JSON.stringify(message)); - }); - } - - close(): void { - try { this.ws.close(); } catch {} - } -} - -function getScriptDir(): string { - return path.dirname(new URL(import.meta.url).pathname); -} - -function copyImageToClipboard(imagePath: string): boolean { - const copyScript = path.join(getScriptDir(), '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 copyScript = path.join(getScriptDir(), 'copy-to-clipboard.ts'); - const result = spawnSync('npx', ['-y', 'bun', copyScript, 'html', '--file', htmlPath], { stdio: 'inherit' }); - return result.status === 0; -} - -function pasteFromClipboard(targetApp?: string, retries = 3, delayMs = 500): boolean { - const pasteScript = path.join(getScriptDir(), 'paste-from-clipboard.ts'); - const args = ['npx', '-y', 'bun', pasteScript, '--retries', String(retries), '--delay', String(delayMs)]; - if (targetApp) { - args.push('--app', targetApp); - } - const result = spawnSync(args[0]!, args.slice(1), { stdio: 'inherit' }); - return result.status === 0; -} - interface ArticleOptions { markdownPath: string; coverImage?: string; @@ -225,7 +79,7 @@ export async function publishArticle(options: ArticleOptions): Promise { await writeFile(htmlPath, parsed.html, 'utf-8'); console.log(`[x-article] HTML saved to: ${htmlPath}`); - const chromePath = options.chromePath ?? findChromeExecutable(); + const chromePath = options.chromePath ?? findChromeExecutable(CHROME_CANDIDATES_BASIC); if (!chromePath) throw new Error('Chrome not found'); await mkdir(profileDir, { recursive: true }); @@ -246,7 +100,7 @@ export async function publishArticle(options: ArticleOptions): Promise { try { const wsUrl = await waitForChromeDebugPort(port, 30_000); - cdp = await CdpConnection.connect(wsUrl, 30_000); + cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 30_000 }); // Get page target const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets'); diff --git a/skills/baoyu-post-to-x/scripts/x-browser.ts b/skills/baoyu-post-to-x/scripts/x-browser.ts index ec1d058..224a48c 100644 --- a/skills/baoyu-post-to-x/scripts/x-browser.ts +++ b/skills/baoyu-post-to-x/scripts/x-browser.ts @@ -1,203 +1,21 @@ -import { spawn, spawnSync } from 'node:child_process'; +import { spawn } from 'node:child_process'; import fs from 'node:fs'; import { mkdir } from 'node:fs/promises'; -import net from 'node:net'; -import os from 'node:os'; -import path from 'node:path'; import process from 'node:process'; +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'; -function getScriptDir(): string { - return path.dirname(new URL(import.meta.url).pathname); -} - -function copyImageToClipboard(imagePath: string): boolean { - const copyScript = path.join(getScriptDir(), 'copy-to-clipboard.ts'); - const result = spawnSync('npx', ['-y', 'bun', copyScript, 'image', imagePath], { stdio: 'inherit' }); - return result.status === 0; -} - -function pasteFromClipboard(targetApp?: string, retries = 3, delayMs = 500): boolean { - const pasteScript = path.join(getScriptDir(), 'paste-from-clipboard.ts'); - const args = ['npx', '-y', 'bun', pasteScript, '--retries', String(retries), '--delay', String(delayMs)]; - if (targetApp) { - args.push('--app', targetApp); - } - const result = spawnSync(args[0]!, args.slice(1), { stdio: 'inherit' }); - return result.status === 0; -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function getFreePort(): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on('error', reject); - server.listen(0, '127.0.0.1', () => { - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(() => reject(new Error('Unable to allocate a free TCP port.'))); - return; - } - const port = address.port; - server.close((err) => { - if (err) reject(err); - else resolve(port); - }); - }); - }); -} - -function findChromeExecutable(): string | undefined { - const override = process.env.X_BROWSER_CHROME_PATH?.trim(); - if (override && fs.existsSync(override)) return override; - - const candidates: string[] = []; - switch (process.platform) { - case 'darwin': - candidates.push( - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta', - '/Applications/Chromium.app/Contents/MacOS/Chromium', - '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', - ); - break; - case 'win32': - candidates.push( - 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', - 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', - 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', - 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', - ); - break; - default: - candidates.push( - '/usr/bin/google-chrome', - '/usr/bin/google-chrome-stable', - '/usr/bin/chromium', - '/usr/bin/chromium-browser', - '/snap/bin/chromium', - '/usr/bin/microsoft-edge', - ); - break; - } - - for (const p of candidates) { - if (fs.existsSync(p)) return p; - } - return undefined; -} - -function getDefaultProfileDir(): string { - const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); - return path.join(base, 'x-browser-profile'); -} - -async function fetchJson(url: string): Promise { - const res = await fetch(url, { redirect: 'follow' }); - if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`); - return (await res.json()) as T; -} - -async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise { - const start = Date.now(); - let lastError: unknown = null; - - while (Date.now() - start < timeoutMs) { - try { - const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`); - if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl; - lastError = new Error('Missing webSocketDebuggerUrl'); - } catch (error) { - lastError = error; - } - await sleep(200); - } - - throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`); -} - -class CdpConnection { - private ws: WebSocket; - private nextId = 0; - private pending = new Map void; reject: (e: Error) => void; timer: ReturnType | null }>(); - private eventHandlers = new Map void>>(); - - 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; 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): Promise { - const ws = new WebSocket(url); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('CDP connection timeout.')), timeoutMs); - ws.addEventListener('open', () => { clearTimeout(timer); resolve(); }); - ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('CDP connection failed.')); }); - }); - return new CdpConnection(ws); - } - - 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(method: string, params?: Record, options?: { sessionId?: string; timeoutMs?: number }): Promise { - const id = ++this.nextId; - const message: Record = { id, method }; - if (params) message.params = params; - if (options?.sessionId) message.sessionId = options.sessionId; - - const timeoutMs = options?.timeoutMs ?? 15_000; - - const result = await new Promise((resolve, reject) => { - const timer = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) : null; - this.pending.set(id, { resolve, reject, timer }); - this.ws.send(JSON.stringify(message)); - }); - - return result as T; - } - - close(): void { - try { this.ws.close(); } catch {} - } -} - interface XBrowserOptions { text?: string; images?: string[]; @@ -210,7 +28,7 @@ interface XBrowserOptions { export async function postToX(options: XBrowserOptions): Promise { const { text, images = [], submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options; - const chromePath = options.chromePath ?? findChromeExecutable(); + 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 }); @@ -231,8 +49,8 @@ export async function postToX(options: XBrowserOptions): Promise { let cdp: CdpConnection | null = null; try { - const wsUrl = await waitForChromeDebugPort(port, 30_000); - cdp = await CdpConnection.connect(wsUrl, 30_000); + 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')); diff --git a/skills/baoyu-post-to-x/scripts/x-quote.ts b/skills/baoyu-post-to-x/scripts/x-quote.ts index 4acf1ea..6d5ba7a 100644 --- a/skills/baoyu-post-to-x/scripts/x-quote.ts +++ b/skills/baoyu-post-to-x/scripts/x-quote.ts @@ -1,175 +1,15 @@ import { spawn } from 'node:child_process'; -import fs from 'node:fs'; import { mkdir } from 'node:fs/promises'; -import net from 'node:net'; -import os from 'node:os'; -import path from 'node:path'; import process from 'node:process'; - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function getFreePort(): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on('error', reject); - server.listen(0, '127.0.0.1', () => { - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(() => reject(new Error('Unable to allocate a free TCP port.'))); - return; - } - const port = address.port; - server.close((err) => { - if (err) reject(err); - else resolve(port); - }); - }); - }); -} - -function findChromeExecutable(): string | undefined { - const override = process.env.X_BROWSER_CHROME_PATH?.trim(); - if (override && fs.existsSync(override)) return override; - - const candidates: string[] = []; - switch (process.platform) { - case 'darwin': - candidates.push( - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta', - '/Applications/Chromium.app/Contents/MacOS/Chromium', - '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', - ); - break; - case 'win32': - candidates.push( - 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', - 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', - 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', - 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', - ); - break; - default: - candidates.push( - '/usr/bin/google-chrome', - '/usr/bin/google-chrome-stable', - '/usr/bin/chromium', - '/usr/bin/chromium-browser', - '/snap/bin/chromium', - '/usr/bin/microsoft-edge', - ); - break; - } - - for (const p of candidates) { - if (fs.existsSync(p)) return p; - } - return undefined; -} - -function getDefaultProfileDir(): string { - const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); - return path.join(base, 'x-browser-profile'); -} - -async function fetchJson(url: string): Promise { - const res = await fetch(url, { redirect: 'follow' }); - if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`); - return (await res.json()) as T; -} - -async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise { - const start = Date.now(); - let lastError: unknown = null; - - while (Date.now() - start < timeoutMs) { - try { - const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`); - if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl; - lastError = new Error('Missing webSocketDebuggerUrl'); - } catch (error) { - lastError = error; - } - await sleep(200); - } - - throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`); -} - -class CdpConnection { - private ws: WebSocket; - private nextId = 0; - private pending = new Map void; reject: (e: Error) => void; timer: ReturnType | null }>(); - private eventHandlers = new Map void>>(); - - 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; 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): Promise { - const ws = new WebSocket(url); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('CDP connection timeout.')), timeoutMs); - ws.addEventListener('open', () => { clearTimeout(timer); resolve(); }); - ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('CDP connection failed.')); }); - }); - return new CdpConnection(ws); - } - - async send(method: string, params?: Record, options?: { sessionId?: string; timeoutMs?: number }): Promise { - const id = ++this.nextId; - const message: Record = { id, method }; - if (params) message.params = params; - if (options?.sessionId) message.sessionId = options.sessionId; - - const timeoutMs = options?.timeoutMs ?? 15_000; - - const result = await new Promise((resolve, reject) => { - const timer = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) : null; - this.pending.set(id, { resolve, reject, timer }); - this.ws.send(JSON.stringify(message)); - }); - - return result as T; - } - - close(): void { - try { this.ws.close(); } catch {} - } -} +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 @@ -191,7 +31,7 @@ interface QuoteOptions { export async function quotePost(options: QuoteOptions): Promise { const { tweetUrl, comment, submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options; - const chromePath = options.chromePath ?? findChromeExecutable(); + 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 }); @@ -213,8 +53,8 @@ export async function quotePost(options: QuoteOptions): Promise { let cdp: CdpConnection | null = null; try { - const wsUrl = await waitForChromeDebugPort(port, 30_000); - cdp = await CdpConnection.connect(wsUrl, 30_000); + 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')); diff --git a/skills/baoyu-post-to-x/scripts/x-utils.ts b/skills/baoyu-post-to-x/scripts/x-utils.ts new file mode 100644 index 0000000..3a2c4fe --- /dev/null +++ b/skills/baoyu-post-to-x/scripts/x-utils.ts @@ -0,0 +1,242 @@ +import { 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'; + +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; +} + +export function getDefaultProfileDir(): string { + const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); + return path.join(base, 'x-browser-profile'); +} + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Unable to allocate a free TCP port.'))); + return; + } + const port = address.port; + server.close((err) => { + if (err) reject(err); + else resolve(port); + }); + }); + }); +} + +async function fetchJson(url: string): Promise { + const res = await fetch(url, { redirect: 'follow' }); + if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`); + return (await res.json()) as T; +} + +export async function waitForChromeDebugPort( + port: number, + timeoutMs: number, + options?: { includeLastError?: boolean }, +): Promise { + const start = Date.now(); + let lastError: unknown = null; + + while (Date.now() - start < timeoutMs) { + try { + const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`); + if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl; + lastError = new Error('Missing webSocketDebuggerUrl'); + } catch (error) { + lastError = error; + } + await sleep(200); + } + + 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 | null; +}; + +export class CdpConnection { + private ws: WebSocket; + private nextId = 0; + private pending = new Map(); + private eventHandlers = new Map 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 { + const ws = new WebSocket(url); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('CDP connection timeout.')), timeoutMs); + ws.addEventListener('open', () => { clearTimeout(timer); resolve(); }); + ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('CDP connection failed.')); }); + }); + return new CdpConnection(ws, 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(method: string, params?: Record, options?: { sessionId?: string; timeoutMs?: number }): Promise { + const id = ++this.nextId; + const message: Record = { id, method }; + if (params) message.params = params; + if (options?.sessionId) message.sessionId = options.sessionId; + + const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs; + + const result = await new Promise((resolve, reject) => { + const timer = timeoutMs > 0 + ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) + : null; + this.pending.set(id, { resolve, reject, timer }); + this.ws.send(JSON.stringify(message)); + }); + + return result as T; + } + + close(): void { + try { this.ws.close(); } catch {} + } +} + +export function getScriptDir(): string { + return path.dirname(new URL(import.meta.url).pathname); +} + +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); +} diff --git a/skills/baoyu-post-to-x/scripts/x-video.ts b/skills/baoyu-post-to-x/scripts/x-video.ts index 3f33fe6..62ec303 100644 --- a/skills/baoyu-post-to-x/scripts/x-video.ts +++ b/skills/baoyu-post-to-x/scripts/x-video.ts @@ -1,172 +1,20 @@ import { spawn } from 'node:child_process'; import fs from 'node:fs'; import { mkdir } from 'node:fs/promises'; -import net from 'node:net'; -import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; +import { + CHROME_CANDIDATES_FULL, + CdpConnection, + findChromeExecutable, + getDefaultProfileDir, + getFreePort, + sleep, + waitForChromeDebugPort, +} from './x-utils.js'; const X_COMPOSE_URL = 'https://x.com/compose/post'; -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function getFreePort(): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on('error', reject); - server.listen(0, '127.0.0.1', () => { - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(() => reject(new Error('Unable to allocate a free TCP port.'))); - return; - } - const port = address.port; - server.close((err) => { - if (err) reject(err); - else resolve(port); - }); - }); - }); -} - -function findChromeExecutable(): string | undefined { - const override = process.env.X_BROWSER_CHROME_PATH?.trim(); - if (override && fs.existsSync(override)) return override; - - const candidates: string[] = []; - switch (process.platform) { - case 'darwin': - candidates.push( - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta', - '/Applications/Chromium.app/Contents/MacOS/Chromium', - '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', - ); - break; - case 'win32': - candidates.push( - 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', - 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', - 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', - 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', - ); - break; - default: - candidates.push( - '/usr/bin/google-chrome', - '/usr/bin/google-chrome-stable', - '/usr/bin/chromium', - '/usr/bin/chromium-browser', - '/snap/bin/chromium', - '/usr/bin/microsoft-edge', - ); - break; - } - - for (const p of candidates) { - if (fs.existsSync(p)) return p; - } - return undefined; -} - -function getDefaultProfileDir(): string { - const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); - return path.join(base, 'x-browser-profile'); -} - -async function fetchJson(url: string): Promise { - const res = await fetch(url, { redirect: 'follow' }); - if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`); - return (await res.json()) as T; -} - -async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise { - const start = Date.now(); - let lastError: unknown = null; - - while (Date.now() - start < timeoutMs) { - try { - const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`); - if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl; - lastError = new Error('Missing webSocketDebuggerUrl'); - } catch (error) { - lastError = error; - } - await sleep(200); - } - - throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`); -} - -class CdpConnection { - private ws: WebSocket; - private nextId = 0; - private pending = new Map void; reject: (e: Error) => void; timer: ReturnType | null }>(); - - private constructor(ws: WebSocket) { - this.ws = ws; - this.ws.addEventListener('message', (event) => { - try { - const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer); - const msg = JSON.parse(data) as { id?: number; result?: unknown; error?: { message?: string } }; - - if (msg.id) { - const pending = this.pending.get(msg.id); - if (pending) { - this.pending.delete(msg.id); - if (pending.timer) clearTimeout(pending.timer); - if (msg.error?.message) pending.reject(new Error(msg.error.message)); - else pending.resolve(msg.result); - } - } - } catch {} - }); - - this.ws.addEventListener('close', () => { - for (const [id, pending] of this.pending.entries()) { - this.pending.delete(id); - if (pending.timer) clearTimeout(pending.timer); - pending.reject(new Error('CDP connection closed.')); - } - }); - } - - static async connect(url: string, timeoutMs: number): Promise { - const ws = new WebSocket(url); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('CDP connection timeout.')), timeoutMs); - ws.addEventListener('open', () => { clearTimeout(timer); resolve(); }); - ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('CDP connection failed.')); }); - }); - return new CdpConnection(ws); - } - - async send(method: string, params?: Record, options?: { sessionId?: string; timeoutMs?: number }): Promise { - const id = ++this.nextId; - const message: Record = { id, method }; - if (params) message.params = params; - if (options?.sessionId) message.sessionId = options.sessionId; - - const timeoutMs = options?.timeoutMs ?? 30_000; - - const result = await new Promise((resolve, reject) => { - const timer = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) : null; - this.pending.set(id, { resolve, reject, timer }); - this.ws.send(JSON.stringify(message)); - }); - - return result as T; - } - - close(): void { - try { this.ws.close(); } catch {} - } -} - interface XVideoOptions { text?: string; videoPath: string; @@ -179,7 +27,7 @@ interface XVideoOptions { export async function postVideoToX(options: XVideoOptions): Promise { const { text, videoPath, submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options; - const chromePath = options.chromePath ?? findChromeExecutable(); + 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}`); @@ -205,8 +53,8 @@ export async function postVideoToX(options: XVideoOptions): Promise { let cdp: CdpConnection | null = null; try { - const wsUrl = await waitForChromeDebugPort(port, 30_000); - cdp = await CdpConnection.connect(wsUrl, 30_000); + 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'));