Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,16 @@ extension/ → Browser extension (Chrome/Edge)

## Key Technical Context

- **ALWAYS use XActions' stealth infrastructure** for any browser automation. NEVER call Puppeteer (or Playwright) directly/raw. Use `createBrowser()`, `createPage()`, `loginWithCookie()`, and `randomDelay()` from `src/scrapers/index.js` — these include puppeteer-extra-plugin-stealth, anti-detection launch args, realistic viewports/user agents, and human-like log-normal delays. See also `src/agents/antiDetection.js` for advanced behavioral simulation (Bezier mouse, human typing, circadian patterns).
- Browser scripts run in **DevTools console on x.com**, not Node.js
- DOM selectors change frequently — see [selectors.md](docs/agents/selectors.md)
- Scripts in `src/automation/` require pasting `src/automation/core.js` first
- State persistence uses `sessionStorage` (lost on tab close)
- CLI entry point: `bin/unfollowx`, installed via `npm install -g xactions`
- MCP server: `src/mcp/server.js` — used by Claude Desktop and AI agents
- Prefer `data-testid` selectors — most stable across X/Twitter UI updates
- X enforces aggressive rate limits; all automation must include 1-3s delays between actions
- X enforces aggressive rate limits; all automation uses human-like log-normal delays (2-7s base + occasional distraction spikes)
- **NEVER run multiple MCP/scraper/CLI requests in parallel** — the MCP server shares a single browser instance. Concurrent requests cause overlapping navigations that break scraping and defeat the human-like timing simulation. Always run one request at a time and wait for it to complete.

## Patterns & Style

Expand Down
240 changes: 237 additions & 3 deletions src/cli/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,29 @@ program

const config = await loadConfig();
config.authToken = cookie;
await saveConfig(config);

console.log(chalk.green('\n✓ Authentication saved!\n'));
// Resolve and save the authenticated username
try {
const browser = await scrapers.createBrowser();
const page = await scrapers.createPage(browser);
await scrapers.loginWithCookie(page, cookie);
await page.goto('https://x.com/home', { waitUntil: 'networkidle2' });
const username = await page.evaluate(() => {
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
return link?.getAttribute('href')?.replace('/', '') || null;
});
await browser.close();
if (username) {
config.username = username;
console.log(chalk.green(`\n✓ Authenticated as @${username}!\n`));
} else {
console.log(chalk.green('\n✓ Authentication saved!\n'));
}
} catch {
console.log(chalk.green('\n✓ Authentication saved!\n'));
}

await saveConfig(config);
});

program
Expand All @@ -155,6 +175,7 @@ program
.action(async () => {
const config = await loadConfig();
delete config.authToken;
delete config.username;
await saveConfig(config);
console.log(chalk.green('\n✓ Logged out successfully\n'));
});
Expand Down Expand Up @@ -523,6 +544,219 @@ program
}
});

// ============================================================================
// DM Commands
// ============================================================================

const dmsCmd = program.command('dms').description('Direct message commands');

async function promptAndSavePasscode(passcode) {
const config = await loadConfig();
if (config.dmPasscode === passcode) return;
const { save } = await inquirer.prompt([{
type: 'confirm',
name: 'save',
message: 'Save passcode for future use? (stored alongside auth_token in ~/.xactions/config.json)',
default: true,
}]);
if (save) {
config.dmPasscode = passcode;
await saveConfig(config);
console.log(chalk.green('Passcode saved.'));
}
}

dmsCmd
.command('list')
.description('List DM conversations')
.option('-l, --limit <number>', 'Maximum conversations', '20')
.option('-o, --output <file>', 'Output file (json, csv, or xlsx)')
.option('--passcode <code>', 'DM encryption passcode (4 digits)')
.action(async (options) => {
const limit = parseInt(options.limit);
const spinner = ora('Fetching DM conversations').start();

try {
const browser = await scrapers.createBrowser();
const page = await scrapers.createPage(browser);

const config = await loadConfig();
if (config.authToken) {
await scrapers.loginWithCookie(page, config.authToken);
}

let passcode = options.passcode || config.dmPasscode;

let conversations;
try {
conversations = await scrapers.scrapeDmConversations(page, { limit, passcode });
} catch (err) {
if (err.message.includes('passcode required') && !passcode) {
spinner.stop();
const answers = await inquirer.prompt([{
type: 'password',
name: 'passcode',
message: 'Enter your 4-digit DM encryption passcode:',
mask: '*',
}]);
passcode = answers.passcode;
spinner.start('Retrying with passcode...');
conversations = await scrapers.scrapeDmConversations(page, { limit, passcode });
} else {
throw err;
}
}

await browser.close();
spinner.succeed(`Found ${conversations.length} conversations`);
if (passcode && passcode !== config.dmPasscode) await promptAndSavePasscode(passcode);
await smartOutput(conversations, options, 'dm-conversations');
} catch (error) {
spinner.fail('Failed to fetch conversations');
console.error(chalk.red(error.message));
}
});

dmsCmd
.command('read <username>')
.description('Read DMs with a specific user')
.option('-l, --limit <number>', 'Maximum messages', '50')
.option('-o, --output <file>', 'Output file (json, csv, or xlsx)')
.option('--passcode <code>', 'DM encryption passcode (4 digits)')
.action(async (username, options) => {
const limit = parseInt(options.limit);
const spinner = ora(`Reading DMs with @${username}`).start();

try {
const browser = await scrapers.createBrowser();
const page = await scrapers.createPage(browser);

const config = await loadConfig();
if (config.authToken) {
await scrapers.loginWithCookie(page, config.authToken);
}

let passcode = options.passcode || config.dmPasscode;

let messages;
try {
messages = await scrapers.scrapeDmMessages(page, username, { limit, passcode });
} catch (err) {
if (err.message.includes('passcode required') && !passcode) {
spinner.stop();
const answers = await inquirer.prompt([{
type: 'password',
name: 'passcode',
message: 'Enter your 4-digit DM encryption passcode:',
mask: '*',
}]);
passcode = answers.passcode;
spinner.start('Retrying with passcode...');
messages = await scrapers.scrapeDmMessages(page, username, { limit, passcode });
} else {
throw err;
}
}

await browser.close();
spinner.succeed(`Read ${messages.length} messages with @${username}`);
if (passcode && passcode !== config.dmPasscode) await promptAndSavePasscode(passcode);

if (!options.output) {
for (const msg of messages) {
const who = msg.sender === 'me' ? chalk.blue('YOU') : chalk.green(username.toUpperCase());
const media = msg.hasMedia ? chalk.yellow(' [+media]') : '';
const time = msg.time ? chalk.gray(msg.time) : '';
console.log(`[${who}] ${time} — ${msg.text}${media}`);
}
} else {
await smartOutput(messages, options, `dms-${username}`);
}
} catch (error) {
spinner.fail(`Failed to read DMs with @${username}`);
console.error(chalk.red(error.message));
}
});

dmsCmd
.command('export')
.description('Export all DM conversations and messages')
.option('-l, --limit <number>', 'Maximum total messages', '100')
.option('-o, --output <file>', 'Output file (json, csv, or xlsx)')
.option('--passcode <code>', 'DM encryption passcode (4 digits)')
.action(async (options) => {
const limit = parseInt(options.limit);
const spinner = ora('Exporting DMs').start();

try {
const browser = await scrapers.createBrowser();
const page = await scrapers.createPage(browser);

const config = await loadConfig();
if (config.authToken) {
await scrapers.loginWithCookie(page, config.authToken);
}

let passcode = options.passcode || config.dmPasscode;

let convos;
try {
convos = await scrapers.scrapeDmConversations(page, { limit: Math.ceil(limit / 10), passcode });
} catch (err) {
if (err.message.includes('passcode required') && !passcode) {
spinner.stop();
const answers = await inquirer.prompt([{
type: 'password',
name: 'passcode',
message: 'Enter your 4-digit DM encryption passcode:',
mask: '*',
}]);
passcode = answers.passcode;
spinner.start('Retrying with passcode...');
convos = await scrapers.scrapeDmConversations(page, { limit: Math.ceil(limit / 10), passcode });
} else {
throw err;
}
}

if (convos.length === 0) {
await browser.close();
spinner.succeed('No conversations found');
return;
}

const allMessages = [];
for (let i = 0; i < convos.length; i++) {
spinner.text = `Exporting DMs (${i + 1}/${convos.length}: ${convos[i].name})`;
const messages = await scrapers.scrapeDmMessages(page, convos[i].name, {
passcode,
limit: Math.ceil(limit / convos.length),
skipNavigation: true,
});
allMessages.push({ conversation: convos[i].name, messages });
}

await browser.close();

const total = allMessages.reduce((sum, c) => sum + c.messages.length, 0);
spinner.succeed(`Exported ${total} messages from ${allMessages.length} conversations`);
if (passcode && passcode !== config.dmPasscode) await promptAndSavePasscode(passcode);

const exportData = { conversations: allMessages, total };
if (options.output) {
const flat = allMessages.flatMap(c =>
c.messages.map(m => ({ conversation: c.conversation, ...m }))
);
await smartOutput(flat, options, 'dm-export');
} else {
console.log(JSON.stringify(exportData, null, 2));
}
} catch (error) {
spinner.fail('Failed to export DMs');
console.error(chalk.red(error.message));
}
});

// ============================================================================
// Plugin Commands
// ============================================================================
Expand Down Expand Up @@ -1742,7 +1976,7 @@ const scrapeCmd = program
};

// Set the right target field based on action
if (['profile', 'followers', 'following', 'tweets', 'posts'].includes(action)) {
if (['profile', 'followers', 'following', 'tweets', 'posts', 'likes', 'media'].includes(action)) {
if (!username) throw new Error(`Action "${action}" requires a username. Usage: xactions scrape ${action} <username> --platform ${platform}`);
scrapeOptions.username = username;
} else if (['search'].includes(action)) {
Expand Down
7 changes: 7 additions & 0 deletions src/client/api/graphqlQueries.js
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,10 @@ export function buildGraphQLUrl(endpoint, variables = {}, features) {

return `${base}?${params.toString()}`;
}

/**
* Default field toggles for GraphQL requests.
*/
export const DEFAULT_FIELD_TOGGLES = {
withArticlePlainText: false,
};
Loading