Files
Regolith/.claude/skills/x-integration/lib/browser.ts
2026-02-04 00:27:04 +02:00

149 lines
4.0 KiB
TypeScript

/**
* X Integration - Shared utilities
* Used by all X scripts
*/
import { chromium, BrowserContext, Page } from 'playwright';
import fs from 'fs';
import path from 'path';
import { config } from './config.js';
export { config };
export interface ScriptResult {
success: boolean;
message: string;
data?: unknown;
}
/**
* Read input from stdin
*/
export async function readInput<T>(): Promise<T> {
return new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => { data += chunk; });
process.stdin.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (err) {
reject(new Error(`Invalid JSON input: ${err}`));
}
});
process.stdin.on('error', reject);
});
}
/**
* Write result to stdout
*/
export function writeResult(result: ScriptResult): void {
console.log(JSON.stringify(result));
}
/**
* Clean up browser lock files
*/
export function cleanupLockFiles(): void {
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
const lockPath = path.join(config.browserDataDir, lockFile);
if (fs.existsSync(lockPath)) {
try { fs.unlinkSync(lockPath); } catch {}
}
}
}
/**
* Validate tweet/reply content
*/
export function validateContent(content: string | undefined, type = 'Tweet'): ScriptResult | null {
if (!content || content.length === 0) {
return { success: false, message: `${type} content cannot be empty` };
}
if (content.length > config.limits.tweetMaxLength) {
return { success: false, message: `${type} exceeds ${config.limits.tweetMaxLength} character limit (current: ${content.length})` };
}
return null; // Valid
}
/**
* Get browser context with persistent profile
*/
export async function getBrowserContext(): Promise<BrowserContext> {
if (!fs.existsSync(config.authPath)) {
throw new Error('X authentication not configured. Run /x-integration to complete login.');
}
cleanupLockFiles();
const context = await chromium.launchPersistentContext(config.browserDataDir, {
executablePath: config.chromePath,
headless: false,
viewport: config.viewport,
args: config.chromeArgs,
ignoreDefaultArgs: config.chromeIgnoreDefaultArgs,
});
return context;
}
/**
* Extract tweet ID from URL or raw ID
*/
export function extractTweetId(input: string): string | null {
const urlMatch = input.match(/(?:x\.com|twitter\.com)\/\w+\/status\/(\d+)/);
if (urlMatch) return urlMatch[1];
if (/^\d+$/.test(input.trim())) return input.trim();
return null;
}
/**
* Navigate to a tweet page
*/
export async function navigateToTweet(
context: BrowserContext,
tweetUrl: string
): Promise<{ page: Page; success: boolean; error?: string }> {
const page = context.pages()[0] || await context.newPage();
let url = tweetUrl;
const tweetId = extractTweetId(tweetUrl);
if (tweetId && !tweetUrl.startsWith('http')) {
url = `https://x.com/i/status/${tweetId}`;
}
try {
await page.goto(url, { timeout: config.timeouts.navigation, waitUntil: 'domcontentloaded' });
await page.waitForTimeout(config.timeouts.pageLoad);
const exists = await page.locator('article[data-testid="tweet"]').first().isVisible().catch(() => false);
if (!exists) {
return { page, success: false, error: 'Tweet not found. It may have been deleted or the URL is invalid.' };
}
return { page, success: true };
} catch (err) {
return { page, success: false, error: `Navigation failed: ${err instanceof Error ? err.message : String(err)}` };
}
}
/**
* Run script with error handling
*/
export async function runScript<T>(
handler: (input: T) => Promise<ScriptResult>
): Promise<void> {
try {
const input = await readInput<T>();
const result = await handler(input);
writeResult(result);
} catch (err) {
writeResult({
success: false,
message: `Script execution failed: ${err instanceof Error ? err.message : String(err)}`
});
process.exit(1);
}
}