chore: release v1.12.0
This commit is contained in:
parent
f43dc2be56
commit
235868343c
|
|
@ -6,7 +6,7 @@
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||||
"version": "1.11.0"
|
"version": "1.12.0"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@
|
||||||
|
|
||||||
English | [中文](./CHANGELOG.zh.md)
|
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
|
## 1.11.0 - 2026-01-21
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@
|
||||||
|
|
||||||
[English](./CHANGELOG.md) | 中文
|
[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
|
## 1.11.0 - 2026-01-21
|
||||||
|
|
||||||
### 新功能
|
### 新功能
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,23 @@
|
||||||
import { spawn, spawnSync } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { mkdir, writeFile } from 'node:fs/promises';
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
import net from 'node:net';
|
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
|
||||||
import { parseMarkdown } from './md-to-html.js';
|
import { parseMarkdown } from './md-to-html.js';
|
||||||
|
import {
|
||||||
|
CHROME_CANDIDATES_BASIC,
|
||||||
|
CdpConnection,
|
||||||
|
copyHtmlToClipboard,
|
||||||
|
copyImageToClipboard,
|
||||||
|
findChromeExecutable,
|
||||||
|
getDefaultProfileDir,
|
||||||
|
getFreePort,
|
||||||
|
pasteFromClipboard,
|
||||||
|
sleep,
|
||||||
|
waitForChromeDebugPort,
|
||||||
|
} from './x-utils.js';
|
||||||
|
|
||||||
const X_ARTICLES_URL = 'https://x.com/compose/articles';
|
const X_ARTICLES_URL = 'https://x.com/compose/articles';
|
||||||
|
|
||||||
|
|
@ -41,163 +52,6 @@ const I18N_SELECTORS = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
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 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 {
|
interface ArticleOptions {
|
||||||
markdownPath: string;
|
markdownPath: string;
|
||||||
coverImage?: string;
|
coverImage?: string;
|
||||||
|
|
@ -225,7 +79,7 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||||
await writeFile(htmlPath, parsed.html, 'utf-8');
|
await writeFile(htmlPath, parsed.html, 'utf-8');
|
||||||
console.log(`[x-article] HTML saved to: ${htmlPath}`);
|
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');
|
if (!chromePath) throw new Error('Chrome not found');
|
||||||
|
|
||||||
await mkdir(profileDir, { recursive: true });
|
await mkdir(profileDir, { recursive: true });
|
||||||
|
|
@ -246,7 +100,7 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const wsUrl = await waitForChromeDebugPort(port, 30_000);
|
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
|
// Get page target
|
||||||
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
||||||
|
|
|
||||||
|
|
@ -1,203 +1,21 @@
|
||||||
import { spawn, spawnSync } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { mkdir } from 'node:fs/promises';
|
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 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';
|
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<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 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<T = unknown>(url: string): Promise<T> {
|
|
||||||
const res = await fetch(url, { redirect: 'follow' });
|
|
||||||
if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`);
|
|
||||||
return (await res.json()) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise<string> {
|
|
||||||
const start = Date.now();
|
|
||||||
let lastError: unknown = null;
|
|
||||||
|
|
||||||
while (Date.now() - start < timeoutMs) {
|
|
||||||
try {
|
|
||||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
|
|
||||||
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
|
|
||||||
lastError = new Error('Missing webSocketDebuggerUrl');
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error;
|
|
||||||
}
|
|
||||||
await sleep(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
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<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> | null }>();
|
|
||||||
private eventHandlers = new Map<string, Set<(params: unknown) => 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<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);
|
|
||||||
}
|
|
||||||
|
|
||||||
on(method: string, handler: (params: unknown) => void): void {
|
|
||||||
if (!this.eventHandlers.has(method)) this.eventHandlers.set(method, new Set());
|
|
||||||
this.eventHandlers.get(method)!.add(handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: { sessionId?: string; timeoutMs?: number }): Promise<T> {
|
|
||||||
const id = ++this.nextId;
|
|
||||||
const message: Record<string, unknown> = { id, method };
|
|
||||||
if (params) message.params = params;
|
|
||||||
if (options?.sessionId) message.sessionId = options.sessionId;
|
|
||||||
|
|
||||||
const timeoutMs = options?.timeoutMs ?? 15_000;
|
|
||||||
|
|
||||||
const result = await new Promise<unknown>((resolve, reject) => {
|
|
||||||
const timer = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) : null;
|
|
||||||
this.pending.set(id, { resolve, reject, timer });
|
|
||||||
this.ws.send(JSON.stringify(message));
|
|
||||||
});
|
|
||||||
|
|
||||||
return result as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
close(): void {
|
|
||||||
try { this.ws.close(); } catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface XBrowserOptions {
|
interface XBrowserOptions {
|
||||||
text?: string;
|
text?: string;
|
||||||
images?: string[];
|
images?: string[];
|
||||||
|
|
@ -210,7 +28,7 @@ interface XBrowserOptions {
|
||||||
export async function postToX(options: XBrowserOptions): Promise<void> {
|
export async function postToX(options: XBrowserOptions): Promise<void> {
|
||||||
const { text, images = [], submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;
|
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.');
|
if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.');
|
||||||
|
|
||||||
await mkdir(profileDir, { recursive: true });
|
await mkdir(profileDir, { recursive: true });
|
||||||
|
|
@ -231,8 +49,8 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
|
||||||
let cdp: CdpConnection | null = null;
|
let cdp: CdpConnection | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const wsUrl = await waitForChromeDebugPort(port, 30_000);
|
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
||||||
cdp = await CdpConnection.connect(wsUrl, 30_000);
|
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');
|
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'));
|
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('x.com'));
|
||||||
|
|
|
||||||
|
|
@ -1,175 +1,15 @@
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
|
||||||
import { mkdir } from 'node:fs/promises';
|
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 process from 'node:process';
|
||||||
|
import {
|
||||||
function sleep(ms: number): Promise<void> {
|
CHROME_CANDIDATES_FULL,
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
CdpConnection,
|
||||||
}
|
findChromeExecutable,
|
||||||
|
getDefaultProfileDir,
|
||||||
async function getFreePort(): Promise<number> {
|
getFreePort,
|
||||||
return new Promise((resolve, reject) => {
|
sleep,
|
||||||
const server = net.createServer();
|
waitForChromeDebugPort,
|
||||||
server.unref();
|
} from './x-utils.js';
|
||||||
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<T = unknown>(url: string): Promise<T> {
|
|
||||||
const res = await fetch(url, { redirect: 'follow' });
|
|
||||||
if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`);
|
|
||||||
return (await res.json()) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise<string> {
|
|
||||||
const start = Date.now();
|
|
||||||
let lastError: unknown = null;
|
|
||||||
|
|
||||||
while (Date.now() - start < timeoutMs) {
|
|
||||||
try {
|
|
||||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
|
|
||||||
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
|
|
||||||
lastError = new Error('Missing webSocketDebuggerUrl');
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error;
|
|
||||||
}
|
|
||||||
await sleep(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
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<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> | null }>();
|
|
||||||
private eventHandlers = new Map<string, Set<(params: unknown) => 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<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 ?? 15_000;
|
|
||||||
|
|
||||||
const result = await new Promise<unknown>((resolve, reject) => {
|
|
||||||
const timer = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) : null;
|
|
||||||
this.pending.set(id, { resolve, reject, timer });
|
|
||||||
this.ws.send(JSON.stringify(message));
|
|
||||||
});
|
|
||||||
|
|
||||||
return result as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
close(): void {
|
|
||||||
try { this.ws.close(); } catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTweetUrl(urlOrId: string): string | null {
|
function extractTweetUrl(urlOrId: string): string | null {
|
||||||
// If it's already a full URL, normalize it
|
// If it's already a full URL, normalize it
|
||||||
|
|
@ -191,7 +31,7 @@ interface QuoteOptions {
|
||||||
export async function quotePost(options: QuoteOptions): Promise<void> {
|
export async function quotePost(options: QuoteOptions): Promise<void> {
|
||||||
const { tweetUrl, comment, submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;
|
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.');
|
if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.');
|
||||||
|
|
||||||
await mkdir(profileDir, { recursive: true });
|
await mkdir(profileDir, { recursive: true });
|
||||||
|
|
@ -213,8 +53,8 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
|
||||||
let cdp: CdpConnection | null = null;
|
let cdp: CdpConnection | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const wsUrl = await waitForChromeDebugPort(port, 30_000);
|
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
||||||
cdp = await CdpConnection.connect(wsUrl, 30_000);
|
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');
|
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'));
|
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('x.com'));
|
||||||
|
|
|
||||||
|
|
@ -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<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 a free TCP port.')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const port = address.port;
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T = unknown>(url: string): Promise<T> {
|
||||||
|
const res = await fetch(url, { redirect: 'follow' });
|
||||||
|
if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`);
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForChromeDebugPort(
|
||||||
|
port: number,
|
||||||
|
timeoutMs: number,
|
||||||
|
options?: { includeLastError?: boolean },
|
||||||
|
): Promise<string> {
|
||||||
|
const start = Date.now();
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
try {
|
||||||
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
|
||||||
|
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
|
||||||
|
lastError = new Error('Missing webSocketDebuggerUrl');
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
await sleep(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.includeLastError && lastError) {
|
||||||
|
throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
||||||
|
}
|
||||||
|
throw new Error('Chrome debug port not ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
type PendingRequest = {
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CdpConnection {
|
||||||
|
private ws: WebSocket;
|
||||||
|
private nextId = 0;
|
||||||
|
private pending = new Map<number, PendingRequest>();
|
||||||
|
private eventHandlers = new Map<string, Set<(params: unknown) => void>>();
|
||||||
|
private defaultTimeoutMs: number;
|
||||||
|
|
||||||
|
private constructor(ws: WebSocket, options?: { defaultTimeoutMs?: number }) {
|
||||||
|
this.ws = ws;
|
||||||
|
this.defaultTimeoutMs = options?.defaultTimeoutMs ?? 15_000;
|
||||||
|
|
||||||
|
this.ws.addEventListener('message', (event) => {
|
||||||
|
try {
|
||||||
|
const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer);
|
||||||
|
const msg = JSON.parse(data) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: { message?: string } };
|
||||||
|
|
||||||
|
if (msg.method) {
|
||||||
|
const handlers = this.eventHandlers.get(msg.method);
|
||||||
|
if (handlers) handlers.forEach((h) => h(msg.params));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.id) {
|
||||||
|
const pending = this.pending.get(msg.id);
|
||||||
|
if (pending) {
|
||||||
|
this.pending.delete(msg.id);
|
||||||
|
if (pending.timer) clearTimeout(pending.timer);
|
||||||
|
if (msg.error?.message) pending.reject(new Error(msg.error.message));
|
||||||
|
else pending.resolve(msg.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.addEventListener('close', () => {
|
||||||
|
for (const [id, pending] of this.pending.entries()) {
|
||||||
|
this.pending.delete(id);
|
||||||
|
if (pending.timer) clearTimeout(pending.timer);
|
||||||
|
pending.reject(new Error('CDP connection closed.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async connect(url: string, timeoutMs: number, options?: { defaultTimeoutMs?: number }): Promise<CdpConnection> {
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error('CDP connection timeout.')), timeoutMs);
|
||||||
|
ws.addEventListener('open', () => { clearTimeout(timer); resolve(); });
|
||||||
|
ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('CDP connection failed.')); });
|
||||||
|
});
|
||||||
|
return new CdpConnection(ws, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
on(method: string, handler: (params: unknown) => void): void {
|
||||||
|
if (!this.eventHandlers.has(method)) this.eventHandlers.set(method, new Set());
|
||||||
|
this.eventHandlers.get(method)!.add(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: { sessionId?: string; timeoutMs?: number }): Promise<T> {
|
||||||
|
const id = ++this.nextId;
|
||||||
|
const message: Record<string, unknown> = { id, method };
|
||||||
|
if (params) message.params = params;
|
||||||
|
if (options?.sessionId) message.sessionId = options.sessionId;
|
||||||
|
|
||||||
|
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
|
||||||
|
|
||||||
|
const result = await new Promise<unknown>((resolve, reject) => {
|
||||||
|
const timer = timeoutMs > 0
|
||||||
|
? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs)
|
||||||
|
: null;
|
||||||
|
this.pending.set(id, { resolve, reject, timer });
|
||||||
|
this.ws.send(JSON.stringify(message));
|
||||||
|
});
|
||||||
|
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
try { this.ws.close(); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScriptDir(): string {
|
||||||
|
return path.dirname(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);
|
||||||
|
}
|
||||||
|
|
@ -1,172 +1,20 @@
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { mkdir } from 'node:fs/promises';
|
import { mkdir } from 'node:fs/promises';
|
||||||
import net from 'node:net';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import process from 'node:process';
|
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';
|
const X_COMPOSE_URL = 'https://x.com/compose/post';
|
||||||
|
|
||||||
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 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<T = unknown>(url: string): Promise<T> {
|
|
||||||
const res = await fetch(url, { redirect: 'follow' });
|
|
||||||
if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`);
|
|
||||||
return (await res.json()) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise<string> {
|
|
||||||
const start = Date.now();
|
|
||||||
let lastError: unknown = null;
|
|
||||||
|
|
||||||
while (Date.now() - start < timeoutMs) {
|
|
||||||
try {
|
|
||||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
|
|
||||||
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
|
|
||||||
lastError = new Error('Missing webSocketDebuggerUrl');
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error;
|
|
||||||
}
|
|
||||||
await sleep(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
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<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;
|
|
||||||
|
|
||||||
const result = await new Promise<unknown>((resolve, reject) => {
|
|
||||||
const timer = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) : null;
|
|
||||||
this.pending.set(id, { resolve, reject, timer });
|
|
||||||
this.ws.send(JSON.stringify(message));
|
|
||||||
});
|
|
||||||
|
|
||||||
return result as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
close(): void {
|
|
||||||
try { this.ws.close(); } catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface XVideoOptions {
|
interface XVideoOptions {
|
||||||
text?: string;
|
text?: string;
|
||||||
videoPath: string;
|
videoPath: string;
|
||||||
|
|
@ -179,7 +27,7 @@ interface XVideoOptions {
|
||||||
export async function postVideoToX(options: XVideoOptions): Promise<void> {
|
export async function postVideoToX(options: XVideoOptions): Promise<void> {
|
||||||
const { text, videoPath, submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;
|
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 (!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}`);
|
if (!fs.existsSync(videoPath)) throw new Error(`Video not found: ${videoPath}`);
|
||||||
|
|
@ -205,8 +53,8 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
|
||||||
let cdp: CdpConnection | null = null;
|
let cdp: CdpConnection | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const wsUrl = await waitForChromeDebugPort(port, 30_000);
|
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
||||||
cdp = await CdpConnection.connect(wsUrl, 30_000);
|
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');
|
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'));
|
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('x.com'));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue