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,
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 0,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "baoyu-post-to-x-scripts",
|
"name": "baoyu-post-to-x-scripts",
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,12 @@ import {
|
||||||
copyImageToClipboard,
|
copyImageToClipboard,
|
||||||
findExistingChromeDebugPort,
|
findExistingChromeDebugPort,
|
||||||
getDefaultProfileDir,
|
getDefaultProfileDir,
|
||||||
|
gracefulKillChrome,
|
||||||
launchChrome,
|
launchChrome,
|
||||||
openPageSession,
|
openPageSession,
|
||||||
pasteFromClipboard,
|
pasteFromClipboard,
|
||||||
sleep,
|
sleep,
|
||||||
|
waitForXSessionPersistence,
|
||||||
waitForChromeDebugPort,
|
waitForChromeDebugPort,
|
||||||
} from './x-utils.js';
|
} 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})`);
|
else console.log(`[x-browser] Launching Chrome (profile: ${profileDir})`);
|
||||||
|
|
||||||
let cdp: CdpConnection | null = null;
|
let cdp: CdpConnection | null = null;
|
||||||
|
let sessionId: string | null = null;
|
||||||
|
let loggedInDuringRun = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
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'),
|
matchTarget: (target) => target.type === 'page' && target.url.includes('x.com'),
|
||||||
enablePage: true,
|
enablePage: true,
|
||||||
enableRuntime: true,
|
enableRuntime: true,
|
||||||
|
enableNetwork: true,
|
||||||
});
|
});
|
||||||
const { sessionId } = page;
|
const activeSessionId = page.sessionId;
|
||||||
await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId });
|
sessionId = activeSessionId;
|
||||||
|
await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId: activeSessionId });
|
||||||
|
|
||||||
console.log('[x-browser] Waiting for X editor...');
|
console.log('[x-browser] Waiting for X editor...');
|
||||||
await sleep(3000);
|
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', {
|
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
|
||||||
expression: `!!document.querySelector('[data-testid="tweetTextarea_0"]')`,
|
expression: `!!document.querySelector('[data-testid="tweetTextarea_0"]')`,
|
||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
}, { sessionId });
|
}, { sessionId: activeSessionId });
|
||||||
if (result.result.value) return true;
|
if (result.result.value) return true;
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
}
|
}
|
||||||
|
|
@ -82,6 +88,7 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
|
||||||
console.log('[x-browser] Waiting for login...');
|
console.log('[x-browser] Waiting for login...');
|
||||||
const loggedIn = await waitForEditor();
|
const loggedIn = await waitForEditor();
|
||||||
if (!loggedIn) throw new Error('Timed out waiting for X editor. Please log in first.');
|
if (!loggedIn) throw new Error('Timed out waiting for X editor. Please log in first.');
|
||||||
|
loggedInDuringRun = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text) {
|
if (text) {
|
||||||
|
|
@ -94,7 +101,7 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
|
||||||
document.execCommand('insertText', false, ${JSON.stringify(text)});
|
document.execCommand('insertText', false, ${JSON.stringify(text)});
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
}, { sessionId });
|
}, { sessionId: activeSessionId });
|
||||||
await sleep(500);
|
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', {
|
const imgCountBefore = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {
|
||||||
expression: `document.querySelectorAll('img[src^="blob:"]').length`,
|
expression: `document.querySelectorAll('img[src^="blob:"]').length`,
|
||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
}, { sessionId });
|
}, { sessionId: activeSessionId });
|
||||||
|
|
||||||
// Wait for clipboard to be ready
|
// Wait for clipboard to be ready
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
@ -123,7 +130,7 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
|
||||||
// Focus the editor
|
// Focus the editor
|
||||||
await cdp.send('Runtime.evaluate', {
|
await cdp.send('Runtime.evaluate', {
|
||||||
expression: `document.querySelector('[data-testid="tweetTextarea_0"]')?.focus()`,
|
expression: `document.querySelector('[data-testid="tweetTextarea_0"]')?.focus()`,
|
||||||
}, { sessionId });
|
}, { sessionId: activeSessionId });
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
|
|
||||||
// Use paste script (handles platform differences, activates Chrome)
|
// Use paste script (handles platform differences, activates Chrome)
|
||||||
|
|
@ -140,14 +147,14 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
|
||||||
code: 'KeyV',
|
code: 'KeyV',
|
||||||
modifiers,
|
modifiers,
|
||||||
windowsVirtualKeyCode: 86,
|
windowsVirtualKeyCode: 86,
|
||||||
}, { sessionId });
|
}, { sessionId: activeSessionId });
|
||||||
await cdp.send('Input.dispatchKeyEvent', {
|
await cdp.send('Input.dispatchKeyEvent', {
|
||||||
type: 'keyUp',
|
type: 'keyUp',
|
||||||
key: 'v',
|
key: 'v',
|
||||||
code: 'KeyV',
|
code: 'KeyV',
|
||||||
modifiers,
|
modifiers,
|
||||||
windowsVirtualKeyCode: 86,
|
windowsVirtualKeyCode: 86,
|
||||||
}, { sessionId });
|
}, { sessionId: activeSessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[x-browser] Verifying image upload...');
|
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', {
|
const r = await cdp!.send<{ result: { value: number } }>('Runtime.evaluate', {
|
||||||
expression: `document.querySelectorAll('img[src^="blob:"]').length`,
|
expression: `document.querySelectorAll('img[src^="blob:"]').length`,
|
||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
}, { sessionId });
|
}, { sessionId: activeSessionId });
|
||||||
if (r.result.value >= expectedImgCount) {
|
if (r.result.value >= expectedImgCount) {
|
||||||
imgUploadOk = true;
|
imgUploadOk = true;
|
||||||
break;
|
break;
|
||||||
|
|
@ -177,18 +184,32 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
|
||||||
console.log('[x-browser] Submitting post...');
|
console.log('[x-browser] Submitting post...');
|
||||||
await cdp.send('Runtime.evaluate', {
|
await cdp.send('Runtime.evaluate', {
|
||||||
expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,
|
expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,
|
||||||
}, { sessionId });
|
}, { sessionId: activeSessionId });
|
||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
console.log('[x-browser] Post submitted!');
|
console.log('[x-browser] Post submitted!');
|
||||||
} else {
|
} else {
|
||||||
console.log('[x-browser] Post composed. Please review and click the publish button in the browser.');
|
console.log('[x-browser] Post composed. Please review and click the publish button in the browser.');
|
||||||
}
|
}
|
||||||
} finally {
|
} 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) {
|
if (cdp) {
|
||||||
cdp.close();
|
cdp.close();
|
||||||
}
|
}
|
||||||
if (chrome) {
|
if (chrome) {
|
||||||
chrome.unref();
|
if (leaveChromeOpen) {
|
||||||
|
chrome.unref();
|
||||||
|
} else {
|
||||||
|
await gracefulKillChrome(chrome, port);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@ import {
|
||||||
CdpConnection,
|
CdpConnection,
|
||||||
findExistingChromeDebugPort,
|
findExistingChromeDebugPort,
|
||||||
getDefaultProfileDir,
|
getDefaultProfileDir,
|
||||||
killChrome,
|
gracefulKillChrome,
|
||||||
launchChrome,
|
launchChrome,
|
||||||
openPageSession,
|
openPageSession,
|
||||||
sleep,
|
sleep,
|
||||||
|
waitForXSessionPersistence,
|
||||||
waitForChromeDebugPort,
|
waitForChromeDebugPort,
|
||||||
} from './x-utils.js';
|
} 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})`);
|
else console.log(`[x-quote] Launching Chrome (profile: ${profileDir})`);
|
||||||
|
|
||||||
let cdp: CdpConnection | null = null;
|
let cdp: CdpConnection | null = null;
|
||||||
|
let sessionId: string | null = null;
|
||||||
let targetId: string | null = null;
|
let targetId: string | null = null;
|
||||||
|
let loggedInDuringRun = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
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'),
|
matchTarget: (target) => target.type === 'page' && target.url.includes('x.com'),
|
||||||
enablePage: true,
|
enablePage: true,
|
||||||
enableRuntime: true,
|
enableRuntime: true,
|
||||||
|
enableNetwork: true,
|
||||||
});
|
});
|
||||||
const { sessionId } = page;
|
const activeSessionId = page.sessionId;
|
||||||
|
sessionId = activeSessionId;
|
||||||
targetId = page.targetId;
|
targetId = page.targetId;
|
||||||
|
|
||||||
console.log('[x-quote] Waiting for tweet to load...');
|
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', {
|
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
|
||||||
expression: `!!document.querySelector('[data-testid="retweet"]')`,
|
expression: `!!document.querySelector('[data-testid="retweet"]')`,
|
||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
}, { sessionId });
|
}, { sessionId: activeSessionId });
|
||||||
if (result.result.value) return true;
|
if (result.result.value) return true;
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
}
|
}
|
||||||
|
|
@ -89,13 +94,14 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
|
||||||
console.log('[x-quote] Waiting for login...');
|
console.log('[x-quote] Waiting for login...');
|
||||||
const loggedIn = await waitForRetweetButton();
|
const loggedIn = await waitForRetweetButton();
|
||||||
if (!loggedIn) throw new Error('Timed out waiting for tweet. Please log in first or check the tweet URL.');
|
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
|
// Click the retweet button
|
||||||
console.log('[x-quote] Clicking retweet button...');
|
console.log('[x-quote] Clicking retweet button...');
|
||||||
await cdp.send('Runtime.evaluate', {
|
await cdp.send('Runtime.evaluate', {
|
||||||
expression: `document.querySelector('[data-testid="retweet"]')?.click()`,
|
expression: `document.querySelector('[data-testid="retweet"]')?.click()`,
|
||||||
}, { sessionId });
|
}, { sessionId: activeSessionId });
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
|
|
||||||
// Wait for and click the "Quote" option in the menu
|
// 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', {
|
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
|
||||||
expression: `!!document.querySelector('[data-testid="Dropdown"] [role="menuitem"]:nth-child(2)')`,
|
expression: `!!document.querySelector('[data-testid="Dropdown"] [role="menuitem"]:nth-child(2)')`,
|
||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
}, { sessionId });
|
}, { sessionId: activeSessionId });
|
||||||
if (result.result.value) return true;
|
if (result.result.value) return true;
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
}
|
}
|
||||||
|
|
@ -121,7 +127,7 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
|
||||||
// Click the quote option (second menu item)
|
// Click the quote option (second menu item)
|
||||||
await cdp.send('Runtime.evaluate', {
|
await cdp.send('Runtime.evaluate', {
|
||||||
expression: `document.querySelector('[data-testid="Dropdown"] [role="menuitem"]:nth-child(2)')?.click()`,
|
expression: `document.querySelector('[data-testid="Dropdown"] [role="menuitem"]:nth-child(2)')?.click()`,
|
||||||
}, { sessionId });
|
}, { sessionId: activeSessionId });
|
||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
|
|
||||||
// Wait for the quote compose dialog
|
// 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', {
|
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
|
||||||
expression: `!!document.querySelector('[data-testid="tweetTextarea_0"]')`,
|
expression: `!!document.querySelector('[data-testid="tweetTextarea_0"]')`,
|
||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
}, { sessionId });
|
}, { sessionId: activeSessionId });
|
||||||
if (result.result.value) return true;
|
if (result.result.value) return true;
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
}
|
}
|
||||||
|
|
@ -150,12 +156,12 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
|
||||||
// Use CDP Input.insertText for more reliable text insertion
|
// Use CDP Input.insertText for more reliable text insertion
|
||||||
await cdp.send('Runtime.evaluate', {
|
await cdp.send('Runtime.evaluate', {
|
||||||
expression: `document.querySelector('[data-testid="tweetTextarea_0"]')?.focus()`,
|
expression: `document.querySelector('[data-testid="tweetTextarea_0"]')?.focus()`,
|
||||||
}, { sessionId });
|
}, { sessionId: activeSessionId });
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
|
|
||||||
await cdp.send('Input.insertText', {
|
await cdp.send('Input.insertText', {
|
||||||
text: comment,
|
text: comment,
|
||||||
}, { sessionId });
|
}, { sessionId: activeSessionId });
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,7 +169,7 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
|
||||||
console.log('[x-quote] Submitting quote post...');
|
console.log('[x-quote] Submitting quote post...');
|
||||||
await cdp.send('Runtime.evaluate', {
|
await cdp.send('Runtime.evaluate', {
|
||||||
expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,
|
expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,
|
||||||
}, { sessionId });
|
}, { sessionId: activeSessionId });
|
||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
console.log('[x-quote] Quote post submitted!');
|
console.log('[x-quote] Quote post submitted!');
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -172,15 +178,29 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
|
||||||
await sleep(30_000);
|
await sleep(30_000);
|
||||||
}
|
}
|
||||||
} finally {
|
} 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 (cdp) {
|
||||||
if (reusing && targetId) {
|
if (reusing && targetId) {
|
||||||
try { await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 }); } catch {}
|
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();
|
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 { execSync, spawnSync } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
@ -8,6 +9,7 @@ import {
|
||||||
findChromeExecutable as findChromeExecutableBase,
|
findChromeExecutable as findChromeExecutableBase,
|
||||||
findExistingChromeDebugPort as findExistingChromeDebugPortBase,
|
findExistingChromeDebugPort as findExistingChromeDebugPortBase,
|
||||||
getFreePort as getFreePortBase,
|
getFreePort as getFreePortBase,
|
||||||
|
gracefulKillChrome,
|
||||||
killChrome,
|
killChrome,
|
||||||
launchChrome as launchChromeBase,
|
launchChrome as launchChromeBase,
|
||||||
openPageSession,
|
openPageSession,
|
||||||
|
|
@ -17,9 +19,21 @@ import {
|
||||||
type PlatformCandidates,
|
type PlatformCandidates,
|
||||||
} from 'baoyu-chrome-cdp';
|
} from 'baoyu-chrome-cdp';
|
||||||
|
|
||||||
export { CdpConnection, killChrome, openPageSession, sleep, waitForChromeDebugPort };
|
export { CdpConnection, gracefulKillChrome, killChrome, openPageSession, sleep, waitForChromeDebugPort };
|
||||||
export type { PlatformCandidates } from 'baoyu-chrome-cdp';
|
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 = {
|
export const CHROME_CANDIDATES_BASIC: PlatformCandidates = {
|
||||||
darwin: [
|
darwin: [
|
||||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||||
|
|
@ -94,15 +108,105 @@ export async function findExistingChromeDebugPort(profileDir: string): Promise<n
|
||||||
return await findExistingChromeDebugPortBase({ profileDir });
|
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,
|
url: string,
|
||||||
profileDir: string,
|
profileDir: string,
|
||||||
candidates: PlatformCandidates,
|
chromePath: string,
|
||||||
chromePathOverride?: string,
|
|
||||||
): Promise<{ chrome: Awaited<ReturnType<typeof launchChromeBase>>; port: number }> {
|
): 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 port = await getFreePort();
|
||||||
const chrome = await launchChromeBase({
|
const chrome = await launchChromeBase({
|
||||||
chromePath,
|
chromePath,
|
||||||
|
|
@ -112,7 +216,37 @@ export async function launchChrome(
|
||||||
extraArgs: ['--start-maximized'],
|
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 {
|
export function getScriptDir(): string {
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,11 @@ import {
|
||||||
CdpConnection,
|
CdpConnection,
|
||||||
findExistingChromeDebugPort,
|
findExistingChromeDebugPort,
|
||||||
getDefaultProfileDir,
|
getDefaultProfileDir,
|
||||||
killChrome,
|
gracefulKillChrome,
|
||||||
launchChrome,
|
launchChrome,
|
||||||
openPageSession,
|
openPageSession,
|
||||||
sleep,
|
sleep,
|
||||||
|
waitForXSessionPersistence,
|
||||||
waitForChromeDebugPort,
|
waitForChromeDebugPort,
|
||||||
} from './x-utils.js';
|
} 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})`);
|
else console.log(`[x-video] Launching Chrome (profile: ${profileDir})`);
|
||||||
|
|
||||||
let cdp: CdpConnection | null = null;
|
let cdp: CdpConnection | null = null;
|
||||||
|
let sessionId: string | null = null;
|
||||||
let targetId: string | null = null;
|
let targetId: string | null = null;
|
||||||
|
let loggedInDuringRun = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
||||||
|
|
@ -63,10 +66,12 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
|
||||||
enablePage: true,
|
enablePage: true,
|
||||||
enableRuntime: true,
|
enableRuntime: true,
|
||||||
enableDom: true,
|
enableDom: true,
|
||||||
|
enableNetwork: true,
|
||||||
});
|
});
|
||||||
const { sessionId } = page;
|
const activeSessionId = page.sessionId;
|
||||||
|
sessionId = activeSessionId;
|
||||||
targetId = page.targetId;
|
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...');
|
console.log('[x-video] Waiting for X editor...');
|
||||||
await sleep(3000);
|
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', {
|
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
|
||||||
expression: `!!document.querySelector('[data-testid="tweetTextarea_0"]')`,
|
expression: `!!document.querySelector('[data-testid="tweetTextarea_0"]')`,
|
||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
}, { sessionId });
|
}, { sessionId: activeSessionId });
|
||||||
if (result.result.value) return true;
|
if (result.result.value) return true;
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
}
|
}
|
||||||
|
|
@ -90,16 +95,17 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
|
||||||
console.log('[x-video] Waiting for login...');
|
console.log('[x-video] Waiting for login...');
|
||||||
const loggedIn = await waitForEditor();
|
const loggedIn = await waitForEditor();
|
||||||
if (!loggedIn) throw new Error('Timed out waiting for X editor. Please log in first.');
|
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)
|
// Upload video FIRST (before typing text to avoid text being cleared)
|
||||||
console.log('[x-video] Uploading video...');
|
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', {
|
const { nodeId } = await cdp.send<{ nodeId: number }>('DOM.querySelector', {
|
||||||
nodeId: root.nodeId,
|
nodeId: root.nodeId,
|
||||||
selector: 'input[type="file"][accept*="video"], input[data-testid="fileInput"], input[type="file"]',
|
selector: 'input[type="file"][accept*="video"], input[data-testid="fileInput"], input[type="file"]',
|
||||||
}, { sessionId });
|
}, { sessionId: activeSessionId });
|
||||||
|
|
||||||
if (!nodeId || nodeId === 0) {
|
if (!nodeId || nodeId === 0) {
|
||||||
throw new Error('Could not find file input for video upload.');
|
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', {
|
await cdp.send('DOM.setFileInputFiles', {
|
||||||
nodeId,
|
nodeId,
|
||||||
files: [absVideoPath],
|
files: [absVideoPath],
|
||||||
}, { sessionId });
|
}, { sessionId: activeSessionId });
|
||||||
console.log('[x-video] Video file set, uploading in background...');
|
console.log('[x-video] Video file set, uploading in background...');
|
||||||
|
|
||||||
// Wait a moment for upload to start, then type text while video processes
|
// 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)});
|
document.execCommand('insertText', false, ${JSON.stringify(text)});
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
}, { sessionId });
|
}, { sessionId: activeSessionId });
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,7 +149,7 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
|
||||||
return { hasMedia, buttonEnabled };
|
return { hasMedia, buttonEnabled };
|
||||||
})()`,
|
})()`,
|
||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
}, { sessionId });
|
}, { sessionId: activeSessionId });
|
||||||
|
|
||||||
const { hasMedia, buttonEnabled } = result.result.value;
|
const { hasMedia, buttonEnabled } = result.result.value;
|
||||||
if (hasMedia && buttonEnabled) {
|
if (hasMedia && buttonEnabled) {
|
||||||
|
|
@ -171,7 +177,7 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
|
||||||
console.log('[x-video] Submitting post...');
|
console.log('[x-video] Submitting post...');
|
||||||
await cdp.send('Runtime.evaluate', {
|
await cdp.send('Runtime.evaluate', {
|
||||||
expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,
|
expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,
|
||||||
}, { sessionId });
|
}, { sessionId: activeSessionId });
|
||||||
await sleep(5000);
|
await sleep(5000);
|
||||||
console.log('[x-video] Post submitted!');
|
console.log('[x-video] Post submitted!');
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -179,13 +185,29 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
|
||||||
console.log('[x-video] Browser stays open for review.');
|
console.log('[x-video] Browser stays open for review.');
|
||||||
}
|
}
|
||||||
} finally {
|
} 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 (cdp) {
|
||||||
if (reusing && submit && targetId) {
|
if (reusing && submit && targetId) {
|
||||||
try { await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 }); } catch {}
|
try { await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 }); } catch {}
|
||||||
}
|
}
|
||||||
cdp.close();
|
cdp.close();
|
||||||
}
|
}
|
||||||
if (chrome && submit) killChrome(chrome);
|
if (chrome && submit) {
|
||||||
|
if (leaveChromeOpen) {
|
||||||
|
chrome.unref();
|
||||||
|
} else {
|
||||||
|
await gracefulKillChrome(chrome, port);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue