Skip to content
Closed
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
8 changes: 8 additions & 0 deletions src/mcp/local-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
scrapeTweets,
searchTweets,
scrapeThread,
scrapeLikedTweets,
scrapeLikes,
scrapeMedia,
scrapeListMembers,
Expand Down Expand Up @@ -630,6 +631,12 @@ export async function x_get_bookmarks({ limit = 100 }) {
return scrapeBookmarks(pg, { limit });
}

export async function x_get_likes({ username, limit = 50, from, to }) {
const { page: pg } = await ensureBrowser();
const likedTweets = await scrapeLikedTweets(pg, username, { limit, from, to });
return { likedTweets, count: likedTweets.length, username };
}

export async function x_clear_bookmarks() {
const { page: pg } = await ensureBrowser();
await pg.goto('https://x.com/i/bookmarks', { waitUntil: 'networkidle2' });
Expand Down Expand Up @@ -1369,6 +1376,7 @@ export const toolMap = {
x_reply,
x_bookmark,
x_get_bookmarks,
x_get_likes,
x_clear_bookmarks,
x_auto_like,
// Discovery
Expand Down
24 changes: 5 additions & 19 deletions src/mcp/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -1903,12 +1903,14 @@ const TOOLS = [
},
{
name: 'x_get_likes',
description: 'Scrape tweets that a user has liked. Shows what content a user engages with.',
description: 'Scrape tweets that a user has liked with rich data. Supports timestamp filtering — likes are reverse chronological, so scrolling stops early when it passes the "from" date.',
inputSchema: {
type: 'object',
properties: {
username: { type: 'string', description: 'Username (without @)' },
limit: { type: 'number', description: 'Maximum liked tweets (default: 50)' },
limit: { type: 'number', description: 'Maximum liked tweets to return (default: 50)' },
from: { type: 'string', description: 'Only include likes from this date onward (e.g. "2026-03-01"). Stops scrolling when older tweets are reached.' },
to: { type: 'string', description: 'Only include likes up to this date (e.g. "2026-03-31"). Skips newer tweets but keeps scrolling.' },
},
required: ['username'],
},
Expand Down Expand Up @@ -2275,7 +2277,7 @@ async function executeTool(name, args) {
const xeepyTools = [
'x_get_replies', 'x_get_hashtag', 'x_get_likers', 'x_get_retweeters',
'x_get_media', 'x_get_recommendations', 'x_get_mentions', 'x_get_quote_tweets',
'x_get_likes', 'x_auto_follow', 'x_follow_engagers', 'x_unfollow_all',
'x_auto_follow', 'x_follow_engagers', 'x_unfollow_all',
'x_smart_unfollow', 'x_quote_tweet', 'x_auto_comment', 'x_auto_retweet',
'x_detect_bots', 'x_find_influencers', 'x_smart_target', 'x_crypto_analyze',
'x_grok_analyze_image', 'x_audience_insights', 'x_engagement_report',
Expand Down Expand Up @@ -2475,22 +2477,6 @@ async function executeXeepyTool(name, args) {
return { quotes, count: quotes.length };
}

case 'x_get_likes': {
const page = await localTools.getPage();
await page.goto(`https://x.com/${args.username}/likes`, { waitUntil: 'networkidle2', timeout: 30000 });
await new Promise(r => setTimeout(r, 3000));
const likedTweets = await page.evaluate((limit) => {
const articles = document.querySelectorAll('article[data-testid="tweet"]');
return Array.from(articles).slice(0, limit).map(el => {
const textEl = el.querySelector('[data-testid="tweetText"]');
const userEl = el.querySelector('[data-testid="User-Name"]');
const timeEl = el.querySelector('time');
return { text: textEl?.textContent || '', author: userEl?.textContent || '', timestamp: timeEl?.getAttribute('datetime') || '' };
});
}, args.limit || 50);
return { likedTweets, count: likedTweets.length, username: args.username };
}

// ── Follow Automation ──
case 'x_auto_follow': {
// Find users via search, then follow them with delays
Expand Down
2 changes: 2 additions & 0 deletions src/scrapers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export const {
scrapeTweets,
searchTweets,
scrapeThread,
scrapeLikedTweets,
scrapeLikes,
scrapeHashtag,
scrapeMedia,
Expand Down Expand Up @@ -308,6 +309,7 @@ export default {
scrapeTweets,
searchTweets,
scrapeThread,
scrapeLikedTweets,
scrapeLikes,
scrapeHashtag,
scrapeMedia,
Expand Down
201 changes: 200 additions & 1 deletion src/scrapers/twitter/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,205 @@ export async function scrapeThread(page, tweetUrl) {
}

// ============================================================================
// Likes Scraper
// Liked Tweets Scraper (a user's liked tweets page)
// ============================================================================

/**
* Scrape a user's liked tweets (x.com/username/likes).
* Different from scrapeLikes which scrapes who liked a specific tweet.
*
* Returns rich data per tweet: text, author, handle, timestamp, link, images,
* quotedTweet, article, card, and engagement stats.
*
* @param {import('puppeteer').Page} page
* @param {string} username
* @param {object} options
* @param {number} [options.limit=50] - Max tweets to return
* @param {string} [options.from] - Only include likes from this date onward (stops scrolling when older)
* @param {string} [options.to] - Only include likes up to this date (skips newer, keeps scrolling)
*/
export async function scrapeLikedTweets(page, username, options = {}) {
const { limit = 50, from, to } = options;

if (!username) throw new Error('Username is required for scrapeLikedTweets');

const fromDate = from ? new Date(from) : null;
const toDate = to ? new Date(to) : null;
if (fromDate && isNaN(fromDate.getTime())) throw new Error(`Invalid "from" date: ${from}`);
if (toDate && isNaN(toDate.getTime())) throw new Error(`Invalid "to" date: ${to}`);

await page.goto(`https://x.com/${username}/likes`, { waitUntil: 'networkidle2', timeout: 30000 });
// Auth check — fail fast on expired cookie
if (page.url().includes('/login') || page.url().includes('/i/flow/login')) {
throw new Error('Authentication failed — cookie may be expired.\n\nRun: xactions login');
}
// Expand viewport so X renders multiple tweets (default 800px only fits ~1)
await page.setViewport({ width: 1280, height: 2400 });
await page.waitForSelector('article[data-testid="tweet"]', { timeout: 10000 }).catch(() => {});
await randomDelay(2000, 3000);

const likedTweets = [];
const seenLinks = new Set();
let emptyScrolls = 0;
let passedFromDate = false;

while (likedTweets.length < limit && emptyScrolls < 8 && !passedFromDate) {
// Click "Show more" buttons one at a time — X re-renders the DOM after
// each click, detaching all other button references.
for (let sm = 0; sm < 20; sm++) {
const btn = await page.$('button[data-testid="tweet-text-show-more-link"]');
if (!btn) break;
try {
await btn.evaluate(b => b.scrollIntoView({ block: 'center' }));
await new Promise(r => setTimeout(r, 300));
await btn.click();
await new Promise(r => setTimeout(r, 1200));
} catch { break; }
}

const newTweets = await page.evaluate(() => {
return Array.from(document.querySelectorAll('article[data-testid="tweet"]')).map(el => {
const avatarContainers = el.querySelectorAll('[data-testid^="UserAvatar-Container-"]');
const mainHandle = avatarContainers[0]?.getAttribute('data-testid')?.replace('UserAvatar-Container-', '') || '';

// Detect quoted tweet via second UserAvatar-Container
let quotedTweet = null;
if (avatarContainers.length >= 2) {
const qtHandle = avatarContainers[1]?.getAttribute('data-testid')?.replace('UserAvatar-Container-', '') || '';
const allTexts = el.querySelectorAll('[data-testid="tweetText"]');
const qtText = allTexts.length >= 2 ? allTexts[1].textContent || '' : '';
const allTimes = el.querySelectorAll('time');
const qtTime = allTimes.length >= 2 ? allTimes[1].getAttribute('datetime') || '' : '';
const allUserNames = el.querySelectorAll('[data-testid="User-Name"]');
const qtDisplayName = allUserNames.length >= 2 ? allUserNames[1].querySelector('span')?.textContent || '' : '';
const qtImages = [];
if (qtHandle) {
el.querySelectorAll('a[href*="/photo/"]').forEach(a => {
if (a.href.includes('/' + qtHandle + '/')) {
const img = a.querySelector('img');
if (img?.src) qtImages.push(img.src);
}
});
}
const qtLinkEl = el.querySelector('a[href*="/' + qtHandle + '/status/"]');
quotedTweet = {
text: qtText, author: qtDisplayName, handle: qtHandle,
timestamp: qtTime, link: qtLinkEl?.href || '', images: qtImages,
};
}

// Main tweet text
const allTexts = el.querySelectorAll('[data-testid="tweetText"]');
const text = allTexts[0]?.textContent || '';
const userEl = el.querySelector('[data-testid="User-Name"]');
const timeEl = el.querySelector('time');
const linkEl = el.querySelector('a[href*="/status/"]');

// Main tweet images
const images = [];
el.querySelectorAll('a[href*="/photo/"]').forEach(a => {
if (a.href.includes('/' + mainHandle + '/')) {
const img = a.querySelector('img');
if (img?.src) images.push(img.src);
}
});

// X Article
let article = null;
const articleCover = el.querySelector('[data-testid="article-cover-image"]');
if (articleCover) {
const contentDiv = articleCover.nextElementSibling;
const childDivs = contentDiv ? contentDiv.querySelectorAll(':scope > div') : [];
article = {
title: childDivs[0]?.textContent?.trim() || '',
description: childDivs[1]?.textContent?.trim() || '',
coverImage: articleCover.querySelector('img')?.src || '',
};
}

// Card/link preview
let card = null;
const cardWrapper = el.querySelector('[data-testid="card.wrapper"]');
if (cardWrapper) {
const cardA = cardWrapper.querySelector('a[href]');
const headingEl = cardWrapper.querySelector('[role="heading"]');
card = {
title: headingEl?.textContent || cardA?.getAttribute('aria-label') || '',
link: cardA?.href || '',
};
}

// Engagement stats
const groupEl = el.querySelector('[role="group"]');
const groupLabel = groupEl?.getAttribute('aria-label') || '';
const parseNum = (p) => { const m = groupLabel.match(p); return m ? m[1] : '0'; };

const handle = userEl?.querySelector('a[href^="/"]')?.getAttribute('href')?.replace('/', '') || '';
const displayName = userEl?.querySelector('span')?.textContent || '';
const tweetLink = linkEl?.href || '';

if (article) {
article.tweetUrl = tweetLink;
if (!quotedTweet && tweetLink) {
const statusMatch = tweetLink.match(/\/status\/(\d+)/);
if (statusMatch) article.url = `https://x.com/${handle}/article/${statusMatch[1]}`;
}
}

return {
text, author: displayName, handle,
timestamp: timeEl?.getAttribute('datetime') || '',
link: tweetLink, images, quotedTweet, article, card,
replies: parseNum(/([\d,]+)\s*repl/),
retweets: parseNum(/([\d,]+)\s*repost/),
likes: parseNum(/([\d,]+)\s*like/),
views: parseNum(/([\d,]+)\s*view/),
};
});
});

let added = 0;
for (const t of newTweets) {
if (!t.link || seenLinks.has(t.link)) continue;
seenLinks.add(t.link);

const tweetDate = t.timestamp ? new Date(t.timestamp) : null;
if (tweetDate) {
if (fromDate && tweetDate < fromDate) { passedFromDate = true; break; }
if (toDate && tweetDate > toDate) continue;
}

if (likedTweets.length < limit) {
likedTweets.push(t);
added++;
}
}

if (added === 0) {
emptyScrolls++;
await randomDelay(2000 + emptyScrolls * 1000, 4000 + emptyScrolls * 1500);
} else {
emptyScrolls = 0;
}

// Scroll to load more content, then wait for DOM to update
await page.evaluate(() => window.scrollBy(0, window.innerHeight));
await page.evaluate(() => new Promise(resolve => {
const target = document.querySelector('[data-testid="primaryColumn"]') || document.body;
const observer = new MutationObserver(() => { observer.disconnect(); resolve(); });
observer.observe(target, { childList: true, subtree: true });
setTimeout(() => { observer.disconnect(); resolve(); }, 3000);
}));
await randomDelay(1000, 2000);
}

// Restore viewport
await page.setViewport({ width: 1280, height: 800 });
return likedTweets;
}

// ============================================================================
// Likes Scraper (who liked a specific tweet)
// ============================================================================

/**
Expand Down Expand Up @@ -938,6 +1136,7 @@ export default {
scrapeTweets,
searchTweets,
scrapeThread,
scrapeLikedTweets,
scrapeLikes,
scrapeHashtag,
scrapeMedia,
Expand Down