Add X integration skill (#52)
This commit is contained in:
148
.claude/skills/x-integration/lib/browser.ts
Normal file
148
.claude/skills/x-integration/lib/browser.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user