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);
|
||||
}
|
||||
}
|
||||
62
.claude/skills/x-integration/lib/config.ts
Normal file
62
.claude/skills/x-integration/lib/config.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* X Integration - Configuration
|
||||
*
|
||||
* All environment-specific settings in one place.
|
||||
* Override via environment variables or modify defaults here.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
|
||||
// Project root - can be overridden for different deployments
|
||||
const PROJECT_ROOT = process.env.NANOCLAW_ROOT || process.cwd();
|
||||
|
||||
/**
|
||||
* Configuration object with all settings
|
||||
*/
|
||||
export const config = {
|
||||
// Chrome executable path
|
||||
// Default: standard macOS Chrome location
|
||||
// Override: CHROME_PATH environment variable
|
||||
chromePath: process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
|
||||
// Browser profile directory for persistent login sessions
|
||||
browserDataDir: path.join(PROJECT_ROOT, 'data', 'x-browser-profile'),
|
||||
|
||||
// Auth state marker file
|
||||
authPath: path.join(PROJECT_ROOT, 'data', 'x-auth.json'),
|
||||
|
||||
// Browser viewport settings
|
||||
viewport: {
|
||||
width: 1280,
|
||||
height: 800,
|
||||
},
|
||||
|
||||
// Timeouts (in milliseconds)
|
||||
timeouts: {
|
||||
navigation: 30000,
|
||||
elementWait: 5000,
|
||||
afterClick: 1000,
|
||||
afterFill: 1000,
|
||||
afterSubmit: 3000,
|
||||
pageLoad: 3000,
|
||||
},
|
||||
|
||||
// X character limits
|
||||
limits: {
|
||||
tweetMaxLength: 280,
|
||||
},
|
||||
|
||||
// Chrome launch arguments
|
||||
chromeArgs: [
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-sync',
|
||||
],
|
||||
|
||||
// Args to ignore when launching Chrome
|
||||
chromeIgnoreDefaultArgs: ['--enable-automation'],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user