feat(baoyu-post-to-x): add X session persistence and Chrome lock recovery
This commit is contained in:
parent
881c03262e
commit
74f4a48ca7
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "baoyu-post-to-x-scripts",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue