feat(baoyu-post-to-x): add X session persistence and Chrome lock recovery

This commit is contained in:
Jim Liu 宝玉 2026-03-31 18:24:17 -05:00
parent 881c03262e
commit 74f4a48ca7
6 changed files with 295 additions and 43 deletions

View File

@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "baoyu-post-to-x-scripts",

View File

@ -7,10 +7,12 @@ import {
copyImageToClipboard,
findExistingChromeDebugPort,
getDefaultProfileDir,
gracefulKillChrome,
launchChrome,
openPageSession,
pasteFromClipboard,
sleep,
waitForXSessionPersistence,
waitForChromeDebugPort,
} from './x-utils.js';
@ -44,6 +46,8 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
else console.log(`[x-browser] Launching Chrome (profile: ${profileDir})`);
let cdp: CdpConnection | null = null;
let sessionId: string | null = null;
let loggedInDuringRun = false;
try {
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
@ -56,9 +60,11 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
matchTarget: (target) => target.type === 'page' && target.url.includes('x.com'),
enablePage: true,
enableRuntime: true,
enableNetwork: true,
});
const { sessionId } = page;
await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId });
const activeSessionId = page.sessionId;
sessionId = activeSessionId;
await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId: activeSessionId });
console.log('[x-browser] Waiting for X editor...');
await sleep(3000);
@ -69,7 +75,7 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('[data-testid="tweetTextarea_0"]')`,
returnByValue: true,
}, { sessionId });
}, { sessionId: activeSessionId });
if (result.result.value) return true;
await sleep(1000);
}
@ -82,6 +88,7 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
console.log('[x-browser] Waiting for login...');
const loggedIn = await waitForEditor();
if (!loggedIn) throw new Error('Timed out waiting for X editor. Please log in first.');
loggedInDuringRun = true;
}
if (text) {
@ -94,7 +101,7 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
document.execCommand('insertText', false, ${JSON.stringify(text)});
}
`,
}, { sessionId });
}, { sessionId: activeSessionId });
await sleep(500);
}
@ -115,7 +122,7 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
const imgCountBefore = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {
expression: `document.querySelectorAll('img[src^="blob:"]').length`,
returnByValue: true,
}, { sessionId });
}, { sessionId: activeSessionId });
// Wait for clipboard to be ready
await sleep(500);
@ -123,7 +130,7 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
// Focus the editor
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetTextarea_0"]')?.focus()`,
}, { sessionId });
}, { sessionId: activeSessionId });
await sleep(200);
// Use paste script (handles platform differences, activates Chrome)
@ -140,14 +147,14 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
code: 'KeyV',
modifiers,
windowsVirtualKeyCode: 86,
}, { sessionId });
}, { sessionId: activeSessionId });
await cdp.send('Input.dispatchKeyEvent', {
type: 'keyUp',
key: 'v',
code: 'KeyV',
modifiers,
windowsVirtualKeyCode: 86,
}, { sessionId });
}, { sessionId: activeSessionId });
}
console.log('[x-browser] Verifying image upload...');
@ -158,7 +165,7 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
const r = await cdp!.send<{ result: { value: number } }>('Runtime.evaluate', {
expression: `document.querySelectorAll('img[src^="blob:"]').length`,
returnByValue: true,
}, { sessionId });
}, { sessionId: activeSessionId });
if (r.result.value >= expectedImgCount) {
imgUploadOk = true;
break;
@ -177,18 +184,32 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
console.log('[x-browser] Submitting post...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,
}, { sessionId });
}, { sessionId: activeSessionId });
await sleep(2000);
console.log('[x-browser] Post submitted!');
} else {
console.log('[x-browser] Post composed. Please review and click the publish button in the browser.');
}
} finally {
let leaveChromeOpen = !submit;
if (chrome && submit && loggedInDuringRun && cdp && sessionId) {
console.log('[x-browser] Waiting for X session cookies to persist...');
const sessionReady = await waitForXSessionPersistence({ cdp, sessionId });
if (!sessionReady) {
console.warn('[x-browser] X session cookies not observed yet. Leaving Chrome open so login can finish persisting.');
leaveChromeOpen = true;
}
}
if (cdp) {
cdp.close();
}
if (chrome) {
chrome.unref();
if (leaveChromeOpen) {
chrome.unref();
} else {
await gracefulKillChrome(chrome, port);
}
}
}
}

View File

@ -5,10 +5,11 @@ import {
CdpConnection,
findExistingChromeDebugPort,
getDefaultProfileDir,
killChrome,
gracefulKillChrome,
launchChrome,
openPageSession,
sleep,
waitForXSessionPersistence,
waitForChromeDebugPort,
} from './x-utils.js';
@ -49,7 +50,9 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
else console.log(`[x-quote] Launching Chrome (profile: ${profileDir})`);
let cdp: CdpConnection | null = null;
let sessionId: string | null = null;
let targetId: string | null = null;
let loggedInDuringRun = false;
try {
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
@ -62,8 +65,10 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
matchTarget: (target) => target.type === 'page' && target.url.includes('x.com'),
enablePage: true,
enableRuntime: true,
enableNetwork: true,
});
const { sessionId } = page;
const activeSessionId = page.sessionId;
sessionId = activeSessionId;
targetId = page.targetId;
console.log('[x-quote] Waiting for tweet to load...');
@ -76,7 +81,7 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('[data-testid="retweet"]')`,
returnByValue: true,
}, { sessionId });
}, { sessionId: activeSessionId });
if (result.result.value) return true;
await sleep(1000);
}
@ -89,13 +94,14 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
console.log('[x-quote] Waiting for login...');
const loggedIn = await waitForRetweetButton();
if (!loggedIn) throw new Error('Timed out waiting for tweet. Please log in first or check the tweet URL.');
loggedInDuringRun = true;
}
// Click the retweet button
console.log('[x-quote] Clicking retweet button...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="retweet"]')?.click()`,
}, { sessionId });
}, { sessionId: activeSessionId });
await sleep(1000);
// Wait for and click the "Quote" option in the menu
@ -106,7 +112,7 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('[data-testid="Dropdown"] [role="menuitem"]:nth-child(2)')`,
returnByValue: true,
}, { sessionId });
}, { sessionId: activeSessionId });
if (result.result.value) return true;
await sleep(200);
}
@ -121,7 +127,7 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
// Click the quote option (second menu item)
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="Dropdown"] [role="menuitem"]:nth-child(2)')?.click()`,
}, { sessionId });
}, { sessionId: activeSessionId });
await sleep(2000);
// Wait for the quote compose dialog
@ -132,7 +138,7 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('[data-testid="tweetTextarea_0"]')`,
returnByValue: true,
}, { sessionId });
}, { sessionId: activeSessionId });
if (result.result.value) return true;
await sleep(200);
}
@ -150,12 +156,12 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
// Use CDP Input.insertText for more reliable text insertion
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetTextarea_0"]')?.focus()`,
}, { sessionId });
}, { sessionId: activeSessionId });
await sleep(200);
await cdp.send('Input.insertText', {
text: comment,
}, { sessionId });
}, { sessionId: activeSessionId });
await sleep(500);
}
@ -163,7 +169,7 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
console.log('[x-quote] Submitting quote post...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,
}, { sessionId });
}, { sessionId: activeSessionId });
await sleep(2000);
console.log('[x-quote] Quote post submitted!');
} else {
@ -172,15 +178,29 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
await sleep(30_000);
}
} finally {
let leaveChromeOpen = false;
if (chrome && loggedInDuringRun && cdp && sessionId) {
console.log('[x-quote] Waiting for X session cookies to persist...');
const sessionReady = await waitForXSessionPersistence({ cdp, sessionId });
if (!sessionReady) {
console.warn('[x-quote] X session cookies not observed yet. Leaving Chrome open so login can finish persisting.');
leaveChromeOpen = true;
}
}
if (cdp) {
if (reusing && targetId) {
try { await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 }); } catch {}
} else if (!reusing) {
try { await cdp.send('Browser.close', {}, { timeoutMs: 5_000 }); } catch {}
}
cdp.close();
}
if (chrome) killChrome(chrome);
if (chrome) {
if (leaveChromeOpen) {
chrome.unref();
} else {
await gracefulKillChrome(chrome, port);
}
}
}
}

View File

@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildXSessionCookieMap,
hasChromeLockArtifacts,
hasRequiredXSessionCookies,
shouldRetryChromeLaunch,
} from "./x-utils.ts";
test("hasChromeLockArtifacts detects Chrome singleton artifacts", () => {
assert.equal(hasChromeLockArtifacts(["SingletonSocket"]), true);
assert.equal(hasChromeLockArtifacts(["chrome.pid"]), true);
assert.equal(hasChromeLockArtifacts(["Cookies", "Preferences"]), false);
});
test("shouldRetryChromeLaunch only retries when no live owner exists", () => {
assert.equal(
shouldRetryChromeLaunch({ lockArtifactsPresent: true, hasLiveOwner: false }),
true,
);
assert.equal(
shouldRetryChromeLaunch({ lockArtifactsPresent: true, hasLiveOwner: true }),
false,
);
assert.equal(
shouldRetryChromeLaunch({ lockArtifactsPresent: false, hasLiveOwner: false }),
false,
);
});
test("buildXSessionCookieMap keeps only non-empty cookies", () => {
assert.deepEqual(
buildXSessionCookieMap([
{ name: "auth_token", value: "auth" },
{ name: "ct0", value: "csrf" },
{ name: "twid", value: "u=123" },
{ name: "ct0", value: "" },
{ name: "", value: "ignored" },
{ name: "gt", value: undefined },
]),
{
auth_token: "auth",
ct0: "csrf",
twid: "u=123",
},
);
});
test("hasRequiredXSessionCookies requires auth_token and ct0", () => {
assert.equal(hasRequiredXSessionCookies({ auth_token: "auth" }), false);
assert.equal(hasRequiredXSessionCookies({ ct0: "csrf" }), false);
assert.equal(hasRequiredXSessionCookies({ auth_token: "auth", ct0: "csrf" }), true);
});

View File

@ -1,4 +1,5 @@
import { execSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
@ -8,6 +9,7 @@ import {
findChromeExecutable as findChromeExecutableBase,
findExistingChromeDebugPort as findExistingChromeDebugPortBase,
getFreePort as getFreePortBase,
gracefulKillChrome,
killChrome,
launchChrome as launchChromeBase,
openPageSession,
@ -17,9 +19,21 @@ import {
type PlatformCandidates,
} from 'baoyu-chrome-cdp';
export { CdpConnection, killChrome, openPageSession, sleep, waitForChromeDebugPort };
export { CdpConnection, gracefulKillChrome, killChrome, openPageSession, sleep, waitForChromeDebugPort };
export type { PlatformCandidates } from 'baoyu-chrome-cdp';
const X_SESSION_URLS = ['https://x.com/', 'https://twitter.com/'] as const;
const REQUIRED_X_SESSION_COOKIES = ['auth_token', 'ct0'] as const;
interface CookieLike {
name?: string;
value?: string | null;
}
interface NetworkGetCookiesResult {
cookies?: CookieLike[];
}
export const CHROME_CANDIDATES_BASIC: PlatformCandidates = {
darwin: [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
@ -94,15 +108,105 @@ export async function findExistingChromeDebugPort(profileDir: string): Promise<n
return await findExistingChromeDebugPortBase({ profileDir });
}
export async function launchChrome(
const CHROME_LOCK_FILES = ['SingletonLock', 'SingletonSocket', 'SingletonCookie', 'chrome.pid'] as const;
export function hasChromeLockArtifacts(entries: readonly string[]): boolean {
return CHROME_LOCK_FILES.some((name) => entries.includes(name));
}
export function shouldRetryChromeLaunch(options: {
lockArtifactsPresent: boolean;
hasLiveOwner: boolean;
}): boolean {
return options.lockArtifactsPresent && !options.hasLiveOwner;
}
export function buildXSessionCookieMap(cookies: readonly CookieLike[]): Record<string, string> {
const cookieMap: Record<string, string> = {};
for (const cookie of cookies) {
const name = cookie.name?.trim();
const value = cookie.value?.trim();
if (!name || !value) {
continue;
}
cookieMap[name] = value;
}
return cookieMap;
}
export function hasRequiredXSessionCookies(cookieMap: Record<string, string>): boolean {
return REQUIRED_X_SESSION_COOKIES.every((name) => Boolean(cookieMap[name]));
}
export async function readXSessionCookieMap(
cdp: CdpConnection,
sessionId?: string,
): Promise<Record<string, string>> {
const { cookies } = await cdp.send<NetworkGetCookiesResult>(
'Network.getCookies',
{ urls: [...X_SESSION_URLS] },
{
sessionId,
timeoutMs: 5_000,
},
);
return buildXSessionCookieMap(cookies ?? []);
}
export async function waitForXSessionPersistence(options: {
cdp: CdpConnection;
sessionId?: string;
timeoutMs?: number;
pollIntervalMs?: number;
}): Promise<boolean> {
const timeoutMs = options.timeoutMs ?? 15_000;
const pollIntervalMs = options.pollIntervalMs ?? 1_000;
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const cookieMap = await readXSessionCookieMap(options.cdp, options.sessionId).catch(() => ({}));
if (hasRequiredXSessionCookies(cookieMap)) {
return true;
}
await sleep(pollIntervalMs);
}
return false;
}
function cleanStaleLockFiles(profileDir: string): void {
for (const name of CHROME_LOCK_FILES) {
try { fs.unlinkSync(path.join(profileDir, name)); } catch {}
}
}
function hasLiveChromeOwner(profileDir: string): boolean {
if (process.platform === 'win32') return false;
try {
const result = spawnSync('ps', ['aux'], {
encoding: 'utf8',
timeout: 5000,
});
if (result.status !== 0 || !result.stdout) return false;
return result.stdout.split('\n').some((line) => line.includes(`--user-data-dir=${profileDir}`));
} catch {
return false;
}
}
async function listProfileDirEntries(profileDir: string): Promise<string[]> {
try {
return await fs.promises.readdir(profileDir);
} catch {
return [];
}
}
async function launchChromeOnce(
url: string,
profileDir: string,
candidates: PlatformCandidates,
chromePathOverride?: string,
chromePath: string,
): Promise<{ chrome: Awaited<ReturnType<typeof launchChromeBase>>; port: number }> {
const chromePath = chromePathOverride?.trim() || findChromeExecutable(candidates);
if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.');
const port = await getFreePort();
const chrome = await launchChromeBase({
chromePath,
@ -112,7 +216,37 @@ export async function launchChrome(
extraArgs: ['--start-maximized'],
});
return { chrome, port };
try {
await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
return { chrome, port };
} catch (error) {
killChrome(chrome);
throw error;
}
}
export async function launchChrome(
url: string,
profileDir: string,
candidates: PlatformCandidates,
chromePathOverride?: string,
): Promise<{ chrome: Awaited<ReturnType<typeof launchChromeBase>>; port: number }> {
const chromePath = chromePathOverride?.trim() || findChromeExecutable(candidates);
if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.');
try {
return await launchChromeOnce(url, profileDir, chromePath);
} catch (error) {
const entries = await listProfileDirEntries(profileDir);
const shouldRetry = shouldRetryChromeLaunch({
lockArtifactsPresent: hasChromeLockArtifacts(entries),
hasLiveOwner: hasLiveChromeOwner(profileDir),
});
if (!shouldRetry) throw error;
cleanStaleLockFiles(profileDir);
return await launchChromeOnce(url, profileDir, chromePath);
}
}
export function getScriptDir(): string {

View File

@ -7,10 +7,11 @@ import {
CdpConnection,
findExistingChromeDebugPort,
getDefaultProfileDir,
killChrome,
gracefulKillChrome,
launchChrome,
openPageSession,
sleep,
waitForXSessionPersistence,
waitForChromeDebugPort,
} from './x-utils.js';
@ -49,7 +50,9 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
else console.log(`[x-video] Launching Chrome (profile: ${profileDir})`);
let cdp: CdpConnection | null = null;
let sessionId: string | null = null;
let targetId: string | null = null;
let loggedInDuringRun = false;
try {
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
@ -63,10 +66,12 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
enablePage: true,
enableRuntime: true,
enableDom: true,
enableNetwork: true,
});
const { sessionId } = page;
const activeSessionId = page.sessionId;
sessionId = activeSessionId;
targetId = page.targetId;
await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId });
await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId: activeSessionId });
console.log('[x-video] Waiting for X editor...');
await sleep(3000);
@ -77,7 +82,7 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('[data-testid="tweetTextarea_0"]')`,
returnByValue: true,
}, { sessionId });
}, { sessionId: activeSessionId });
if (result.result.value) return true;
await sleep(1000);
}
@ -90,16 +95,17 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
console.log('[x-video] Waiting for login...');
const loggedIn = await waitForEditor();
if (!loggedIn) throw new Error('Timed out waiting for X editor. Please log in first.');
loggedInDuringRun = true;
}
// Upload video FIRST (before typing text to avoid text being cleared)
console.log('[x-video] Uploading video...');
const { root } = await cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {}, { sessionId });
const { root } = await cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {}, { sessionId: activeSessionId });
const { nodeId } = await cdp.send<{ nodeId: number }>('DOM.querySelector', {
nodeId: root.nodeId,
selector: 'input[type="file"][accept*="video"], input[data-testid="fileInput"], input[type="file"]',
}, { sessionId });
}, { sessionId: activeSessionId });
if (!nodeId || nodeId === 0) {
throw new Error('Could not find file input for video upload.');
@ -108,7 +114,7 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
await cdp.send('DOM.setFileInputFiles', {
nodeId,
files: [absVideoPath],
}, { sessionId });
}, { sessionId: activeSessionId });
console.log('[x-video] Video file set, uploading in background...');
// Wait a moment for upload to start, then type text while video processes
@ -125,7 +131,7 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
document.execCommand('insertText', false, ${JSON.stringify(text)});
}
`,
}, { sessionId });
}, { sessionId: activeSessionId });
await sleep(500);
}
@ -143,7 +149,7 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
return { hasMedia, buttonEnabled };
})()`,
returnByValue: true,
}, { sessionId });
}, { sessionId: activeSessionId });
const { hasMedia, buttonEnabled } = result.result.value;
if (hasMedia && buttonEnabled) {
@ -171,7 +177,7 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
console.log('[x-video] Submitting post...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,
}, { sessionId });
}, { sessionId: activeSessionId });
await sleep(5000);
console.log('[x-video] Post submitted!');
} else {
@ -179,13 +185,29 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
console.log('[x-video] Browser stays open for review.');
}
} finally {
let leaveChromeOpen = !submit;
if (chrome && submit && loggedInDuringRun && cdp && sessionId) {
console.log('[x-video] Waiting for X session cookies to persist...');
const sessionReady = await waitForXSessionPersistence({ cdp, sessionId });
if (!sessionReady) {
console.warn('[x-video] X session cookies not observed yet. Leaving Chrome open so login can finish persisting.');
leaveChromeOpen = true;
}
}
if (cdp) {
if (reusing && submit && targetId) {
try { await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 }); } catch {}
}
cdp.close();
}
if (chrome && submit) killChrome(chrome);
if (chrome && submit) {
if (leaveChromeOpen) {
chrome.unref();
} else {
await gracefulKillChrome(chrome, port);
}
}
}
}