diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..2ac509e Binary files /dev/null and b/.DS_Store differ diff --git a/README.md b/README.md index 56eb394..44f1f81 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,9 @@ const followerResults = await scraper.fetchProfileFollowers('12345', 100); // Fetch a page of who a user is following const followingResults = await scraper.fetchProfileFollowing('12345', 100); + +// Follow a user +const followUserResults = await scraper.followUser('elonmusk'); ``` ### Trends @@ -205,4 +208,65 @@ const latestTweet = await scraper.getLatestTweet('TwitterDev'); // Get a specific tweet by ID const tweet = await scraper.getTweet('1234567890123456789'); + +// Send a tweet +const sendTweetResults = await scraper.sendTweet('Hello world!'); + +// Send a quote tweet - Media files are optional +const sendQuoteTweetResults = await scraper.sendQuoteTweet('Hello world!', '1234567890123456789', ['mediaFile1', 'mediaFile2']); + +// Retweet a tweet +const retweetResults = await scraper.retweet('1234567890123456789'); + +// Like a tweet +const likeTweetResults = await scraper.likeTweet('1234567890123456789'); ``` + +## Sending Tweets with Media + +### Media Handling +The scraper requires media files to be processed into a specific format before sending: +- Media must be converted to Buffer format +- Each media file needs its MIME type specified +- This helps the scraper distinguish between image and video processing models + +### Basic Tweet with Media +```ts +// Example: Sending a tweet with media attachments +const mediaData = [ + { + data: fs.readFileSync('path/to/image.jpg'), + mediaType: 'image/jpeg' + }, + { + data: fs.readFileSync('path/to/video.mp4'), + mediaType: 'video/mp4' + } +]; + +await scraper.sendTweet('Hello world!', undefined, mediaData); +``` + +### Supported Media Types +```ts +// Image formats and their MIME types +const imageTypes = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif' +}; + +// Video format +const videoTypes = { + '.mp4': 'video/mp4' +}; +``` + + +### Media Upload Limitations +- Maximum 4 images per tweet +- Only 1 video per tweet +- Maximum video file size: 512MB +- Supported image formats: JPG, PNG, GIF +- Supported video format: MP4 diff --git a/command.ts b/command.ts new file mode 100644 index 0000000..5353520 --- /dev/null +++ b/command.ts @@ -0,0 +1,490 @@ +// Declare the types for our custom global properties +declare global { + var PLATFORM_NODE: boolean; + var PLATFORM_NODE_JEST: boolean; +} + +// Define platform constants before imports +globalThis.PLATFORM_NODE = typeof process !== 'undefined' && ( + // Node.js check + (process.versions?.node != null) || + // Bun check + (process.versions?.bun != null) +); +globalThis.PLATFORM_NODE_JEST = false; + +// Your existing imports +import { Scraper } from './src/scraper'; +import { Tweet } from './src/tweets'; +import fs from 'fs'; +import path from 'path'; +import readline from 'readline'; +import dotenv from 'dotenv'; + +// Load environment variables from .env file +dotenv.config(); + +// Create a new Scraper instance +const scraper = new Scraper(); + +// Create readline interface for CLI +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + prompt: '> ' +}); + +// Function to log in and save cookies +async function loginAndSaveCookies() { + try { + // Log in using credentials from environment variables + await scraper.login( + process.env.TWITTER_USERNAME!, + process.env.TWITTER_PASSWORD!, + process.env.TWITTER_EMAIL + ); + + // Retrieve the current session cookies + const cookies = await scraper.getCookies(); + + // Save the cookies to a JSON file for future sessions + fs.writeFileSync( + path.resolve(__dirname, 'cookies.json'), + JSON.stringify(cookies) + ); + + console.log('Logged in and cookies saved.'); + } catch (error) { + console.error('Error during login:', error); + } +} + +// Function to load cookies from the JSON file +async function loadCookies() { + try { + // Read cookies from the file system + const cookiesData = fs.readFileSync( + path.resolve(__dirname, 'cookies.json'), + 'utf8' + ); + const cookiesArray = JSON.parse(cookiesData); + + // Map cookies to the correct format (strings) + const cookieStrings = cookiesArray.map((cookie: any) => { + return `${cookie.key}=${cookie.value}; Domain=${cookie.domain}; Path=${cookie.path}; ${ + cookie.secure ? 'Secure' : '' + }; ${cookie.httpOnly ? 'HttpOnly' : ''}; SameSite=${ + cookie.sameSite || 'Lax' + }`; + }); + + // Set the cookies for the current session + await scraper.setCookies(cookieStrings); + + console.log('Cookies loaded from file.'); + } catch (error) { + console.error('Error loading cookies:', error); + } +} + +// Function to ensure the scraper is authenticated +async function ensureAuthenticated() { + // Check if cookies.json exists to decide whether to log in or load cookies + if (fs.existsSync(path.resolve(__dirname, 'cookies.json'))) { + // Load cookies if the file exists + await loadCookies(); + + // Inform the user that they are already logged in + console.log('You are already logged in. No need to log in again.'); + } else { + // Log in and save cookies if no cookie file is found + await loginAndSaveCookies(); + } +} + +// Function to send a tweet with optional media files +async function sendTweetCommand( + text: string, + mediaFiles?: string[], + replyToTweetId?: string +): Promise { + try { + let mediaData; + + if (mediaFiles && mediaFiles.length > 0) { + // Prepare media data by reading files and determining media types + mediaData = await Promise.all( + mediaFiles.map(async (filePath) => { + const absolutePath = path.resolve(__dirname, filePath); + const buffer = await fs.promises.readFile(absolutePath); + const ext = path.extname(filePath).toLowerCase(); + const mediaType = getMediaType(ext); + return { data: buffer, mediaType }; + }) + ); + } + + // Send the tweet using the updated sendTweet function + const response = await scraper.sendTweet(text, replyToTweetId, mediaData); + + // Parse the response to extract the tweet ID + const responseData = await response.json(); + const tweetId = + responseData?.data?.create_tweet?.tweet_results?.result?.rest_id; + + if (tweetId) { + console.log(`Tweet sent: "${text}" (ID: ${tweetId})`); + return tweetId; + } else { + console.error('Tweet ID not found in response.'); + return null; + } + } catch (error) { + console.error('Error sending tweet:', error); + return null; + } +} + +// Function to get media type based on file extension +function getMediaType(ext: string): string { + switch (ext) { + case '.jpg': + case '.jpeg': + return 'image/jpeg'; + case '.png': + return 'image/png'; + case '.gif': + return 'image/gif'; + case '.mp4': + return 'video/mp4'; + // Add other media types as needed + default: + return 'application/octet-stream'; + } +} + +// Function to get replies to a specific tweet +async function getRepliesToTweet(tweetId: string): Promise { + const replies: Tweet[] = []; + try { + // Construct the search query to find replies + const query = `to:${process.env.TWITTER_USERNAME} conversation_id:${tweetId}`; + const maxReplies = 100; // Maximum number of replies to fetch + const searchMode = 1; // SearchMode.Latest + + // Fetch replies matching the query + for await (const tweet of scraper.searchTweets(query, maxReplies, searchMode)) { + // Check if the tweet is a direct reply to the original tweet + if (tweet.inReplyToStatusId === tweetId) { + replies.push(tweet); + } + } + + console.log(`Found ${replies.length} replies to tweet ID ${tweetId}.`); + } catch (error) { + console.error('Error fetching replies:', error); + } + return replies; +} + +// Function to reply to a specific tweet +async function replyToTweet(tweetId: string, text: string) { + try { + // Pass empty array for mediaFiles (2nd param) and tweetId as replyToTweetId (3rd param) + const replyId = await sendTweetCommand(text, [], tweetId); + + if (replyId) { + console.log(`Reply sent (ID: ${replyId}).`); + } + } catch (error) { + console.error('Error sending reply:', error); + } +} + +// Function to get photos from a specific tweet +async function getPhotosFromTweet(tweetId: string) { + try { + // Fetch the tweet by its ID + const tweet = await scraper.getTweet(tweetId); + + // Check if the tweet exists and contains photos + if (tweet && tweet.photos.length > 0) { + console.log(`Found ${tweet.photos.length} photo(s) in tweet ID ${tweetId}:`); + // Iterate over each photo and display its URL + tweet.photos.forEach((photo, index) => { + console.log(`Photo ${index + 1}: ${photo.url}`); + }); + } else { + console.log('No photos found in the specified tweet.'); + } + } catch (error) { + console.error('Error fetching tweet:', error); + } +} + +// Function to parse command line while preserving quoted strings +function parseCommandLine(commandLine: string): string[] { + const args: string[] = []; + let currentArg = ''; + let inQuotes = false; + + for (let i = 0; i < commandLine.length; i++) { + const char = commandLine[i]; + + if (char === '"') { + inQuotes = !inQuotes; + continue; + } + + if (char === ' ' && !inQuotes) { + if (currentArg) { + args.push(currentArg); + currentArg = ''; + } + } else { + currentArg += char; + } + } + + if (currentArg) { + args.push(currentArg); + } + + return args; +} + +// Function to execute commands +async function executeCommand(commandLine: string) { + const args = parseCommandLine(commandLine); + const command = args.shift(); // Remove and get the first element as command + + if (!command) return; + + switch (command) { + case 'login': + await loginAndSaveCookies(); + break; + + case 'send-tweet': { + await ensureAuthenticated(); + + // First argument is the tweet text + const tweetText = args[0]; + // Remaining arguments are media file paths + const mediaFiles = args.slice(1); + + if (!tweetText) { + console.log('Please provide text for the tweet.'); + } else { + // Call the sendTweetCommand with optional media files + await sendTweetCommand(tweetText, mediaFiles); + } + break; + } + + case 'get-tweets': + await ensureAuthenticated(); + const username = args[0]; + if (!username) { + console.log('Please provide a username.'); + } else { + try { + const maxTweets = 20; // Maximum number of tweets to fetch + const tweets: Tweet[] = []; + for await (const tweet of scraper.getTweets(username, maxTweets)) { + tweets.push(tweet); + } + console.log(`Fetched ${tweets.length} tweets from @${username}:`); + tweets.forEach((tweet) => { + console.log(`- [${tweet.id}] ${tweet.text}`); + }); + } catch (error) { + console.error('Error fetching tweets:', error); + } + } + break; + + case 'get-replies': { + await ensureAuthenticated(); + const tweetId = args[0]; + if (!tweetId) { + console.log('Please provide a tweet ID.'); + } else { + const replies = await getRepliesToTweet(tweetId); + console.log(`Found ${replies.length} replies:`); + replies.forEach((reply) => { + console.log(`- @${reply.username}: ${reply.text}`); + }); + } + break; + } + + case 'reply-to-tweet': + await ensureAuthenticated(); + const replyTweetId = args[0]; + const replyText = args.slice(1).join(' '); + if (!replyTweetId || !replyText) { + console.log('Please provide a tweet ID and text to reply.'); + } else { + await replyToTweet(replyTweetId, replyText); + } + break; + + case 'get-mentions': + await ensureAuthenticated(); + try { + const maxTweets = 20; // Maximum number of mentions to fetch + const mentions: Tweet[] = []; + const query = `@${process.env.TWITTER_USERNAME}`; + const searchMode = 1; // SearchMode.Latest + + // Fetch recent mentions + for await (const tweet of scraper.searchTweets(query, maxTweets, searchMode)) { + // Exclude your own tweets + if (tweet.username !== process.env.TWITTER_USERNAME) { + mentions.push(tweet); + } + } + console.log(`Found ${mentions.length} mentions:`); + mentions.forEach((tweet) => { + console.log(`- [${tweet.id}] @${tweet.username}: ${tweet.text}`); + }); + + // Fetch replies to each mention + for (const mention of mentions) { + // Get replies to the mention + const replies = await getRepliesToTweet(mention.id!); + console.log(`Replies to mention [${mention.id}] by @${mention.username}:`); + replies.forEach((reply) => { + console.log(`- [${reply.id}] @${reply.username}: ${reply.text}`); + }); + } + } catch (error) { + console.error('Error fetching mentions:', error); + } + break; + + case 'help': + console.log('Available commands:'); + console.log(' login - Login to Twitter and save cookies'); + console.log(' send-tweet [mediaFiles...] - Send a tweet with optional media attachments'); + console.log(' get-tweets - Get recent tweets from the specified user'); + console.log(' get-replies - Get replies to the specified tweet ID'); + console.log(' reply-to-tweet - Reply to a tweet with the specified text'); + console.log(' get-mentions - Get recent mentions of your account'); + console.log(' exit - Exit the application'); + console.log(' help - Show this help message'); + console.log(' send-quote-tweet "" [mediaFiles...] - Send a quote tweet with optional media attachments'); + console.log(' get-photos - Get photos from a specific tweet'); + console.log(' like - Like a tweet by its ID'); + console.log(' retweet - Retweet a tweet by its ID'); + console.log(' follow - Follow a user by their username'); + break; + + case 'exit': + console.log('Exiting...'); + rl.close(); + process.exit(0); + break; + + case 'get-photos': { + await ensureAuthenticated(); + const tweetId = args[0]; + if (!tweetId) { + console.log('Please provide a tweet ID.'); + } else { + await getPhotosFromTweet(tweetId); + } + break; + } + + case 'send-quote-tweet': { + await ensureAuthenticated(); + + if (args.length < 2) { + console.log( + 'Usage: send-quote-tweet "" [mediaFile1] [mediaFile2] ...' + ); + break; + } + + const quotedTweetId = args[0]; + const text = args[1]; + const mediaFiles = args.slice(2); + + // Prepare the quote tweet text including the quoted tweet URL + const quoteTweetText = `${text} https://twitter.com/user/status/${quotedTweetId}`; + + // Send the quote tweet using the sendTweetCommand function + await sendTweetCommand(quoteTweetText, mediaFiles); + break; + } + + case 'like': + await ensureAuthenticated(); + const tweetId = args[0]; + if (!tweetId) { + console.log('Please provide a tweet ID.'); + } else { + try { + // Attempt to like the tweet + await scraper.likeTweet(tweetId); + console.log(`Tweet ID ${tweetId} liked successfully.`); + } catch (error) { + console.error('Error liking tweet:', error); + } + } + break; + + case 'retweet': + await ensureAuthenticated(); + const retweetId = args[0]; + if (!retweetId) { + console.log('Please provide a tweet ID.'); + } else { + try { + // Attempt to retweet the tweet + await scraper.retweet(retweetId); + console.log(`Tweet ID ${retweetId} retweeted successfully.`); + } catch (error) { + console.error('Error retweeting tweet:', error); + } + } + break; + + case 'follow': + await ensureAuthenticated(); + const usernameToFollow = args[0]; + if (!usernameToFollow) { + console.log('Please provide a username to follow.'); + } else { + try { + // Attempt to follow the user + await scraper.followUser(usernameToFollow); + console.log(`Successfully followed user @${usernameToFollow}.`); + } catch (error) { + console.error('Error following user:', error); + } + } + break; + + default: + console.log(`Unknown command: ${command}. Type 'help' to see available commands.`); + break; + } +} + +// Main function to start the CLI +(async () => { + console.log('Welcome to the Twitter CLI Interface!'); + console.log("Type 'help' to see available commands."); + rl.prompt(); + + rl.on('line', async (line) => { + await executeCommand(line); + rl.prompt(); + }).on('close', () => { + console.log('Goodbye!'); + process.exit(0); + }); +})(); diff --git a/package.json b/package.json index 6421204..663f684 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@sinclair/typebox": "^0.32.20", "headers-polyfill": "^3.1.2", "json-stable-stringify": "^1.0.2", + "node-fetch": "^3.3.2", "otpauth": "^9.2.2", "set-cookie-parser": "^2.6.0", "tough-cookie": "^4.1.2", @@ -41,7 +42,7 @@ "@tsconfig/node16": "^16.1.3", "@types/jest": "^29.5.1", "@types/json-stable-stringify": "^1.0.34", - "@types/node": "^22.9.0", + "@types/node": "^22.9.1", "@types/set-cookie-parser": "^2.4.2", "@types/tough-cookie": "^4.0.2", "@typescript-eslint/eslint-plugin": "^5.59.7", diff --git a/src/relationships.ts b/src/relationships.ts index 422e311..7067377 100644 --- a/src/relationships.ts +++ b/src/relationships.ts @@ -1,4 +1,5 @@ -import { addApiFeatures, requestApi } from './api'; +import { addApiFeatures, requestApi, bearerToken } from './api'; +import { Headers } from 'headers-polyfill'; import { TwitterAuth } from './auth'; import { Profile, getUserIdByScreenName } from './profile'; import { QueryProfilesResponse } from './timeline-v1'; @@ -156,3 +157,66 @@ async function getFollowersTimeline( return res.value; } + +export async function followUser( + username: string, + auth: TwitterAuth, +): Promise { + + // Check if the user is logged in + if (!(await auth.isLoggedIn())) { + throw new Error('Must be logged in to follow users'); + } + // Get user ID from username + const userIdResult = await getUserIdByScreenName(username, auth); + + if (!userIdResult.success) { + throw new Error(`Failed to get user ID: ${userIdResult.err.message}`); + } + + const userId = userIdResult.value; + + // Prepare the request body + const requestBody = { + include_profile_interstitial_type: '1', + skip_status: 'true', + user_id: userId, + }; + + // Prepare the headers + const headers = new Headers({ + 'Content-Type': 'application/x-www-form-urlencoded', + Referer: `https://twitter.com/${username}`, + 'X-Twitter-Active-User': 'yes', + 'X-Twitter-Auth-Type': 'OAuth2Session', + 'X-Twitter-Client-Language': 'en', + Authorization: `Bearer ${bearerToken}`, + }); + + // Install auth headers + await auth.installTo(headers, 'https://api.twitter.com/1.1/friendships/create.json'); + + // Make the follow request using auth.fetch + const res = await auth.fetch( + 'https://api.twitter.com/1.1/friendships/create.json', + { + method: 'POST', + headers, + body: new URLSearchParams(requestBody).toString(), + credentials: 'include', + }, + ); + + if (!res.ok) { + throw new Error(`Failed to follow user: ${res.statusText}`); + } + + const data = await res.json(); + + return new Response(JSON.stringify(data), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }); +} \ No newline at end of file diff --git a/src/scraper.ts b/src/scraper.ts index 4c747bd..e08a6ef 100644 --- a/src/scraper.ts +++ b/src/scraper.ts @@ -25,6 +25,7 @@ import { fetchProfileFollowers, getFollowing, getFollowers, + followUser } from './relationships'; import { QueryProfilesResponse, QueryTweetsResponse } from './timeline-v1'; import { getTrends } from './trends'; @@ -47,6 +48,9 @@ import { getTweetV2, getTweetsV2, defaultOptions, + createQuoteTweetRequest, + likeTweet, + retweet, } from './tweets'; import { parseTimelineTweetsV2, TimelineV2 } from './timeline-v2'; import { fetchHomeTimeline } from './timeline-home'; @@ -420,11 +424,16 @@ export class Scraper { * Send a tweet * @param text The text of the tweet * @param tweetId The id of the tweet to reply to + * @param mediaData Optional media data * @returns */ - async sendTweet(text: string, replyToTweetId?: string) { - return await createCreateTweetRequest(text, this.auth, replyToTweetId); + async sendTweet( + text: string, + replyToTweetId?: string, + mediaData?: { data: Buffer; mediaType: string }[], + ) { + return await createCreateTweetRequest(text, this.auth, replyToTweetId, mediaData); } /** @@ -731,25 +740,57 @@ export class Scraper { } /** - * Send a tweet with optional media attachments - * @param text The text of the tweet - * @param mediaData Array of Buffer containing image data - * @param tweetId Optional ID of tweet to reply to - * @returns Response from Twitter API + * Sends a quote tweet. + * @param text The text of the tweet. + * @param quotedTweetId The ID of the tweet to quote. + * @param options Optional parameters, such as media data. + * @returns The response from the Twitter API. */ - async sendTweetWithMedia( + public async sendQuoteTweet( text: string, - mediaData: Buffer[], - replyToTweetId?: string, + quotedTweetId: string, + options?: { + mediaData: { data: Buffer; mediaType: string }[], + }, ) { - return await createCreateTweetRequest( + return await createQuoteTweetRequest( text, + quotedTweetId, this.auth, - replyToTweetId, - mediaData, + options?.mediaData, ); } + /** + * Likes a tweet with the given tweet ID. + * @param tweetId The ID of the tweet to like. + * @returns A promise that resolves when the tweet is liked. + */ + public async likeTweet(tweetId: string): Promise { + // Call the likeTweet function from tweets.ts + await likeTweet(tweetId, this.auth); + } + + /** + * Retweets a tweet with the given tweet ID. + * @param tweetId The ID of the tweet to retweet. + * @returns A promise that resolves when the tweet is retweeted. + */ + public async retweet(tweetId: string): Promise { + // Call the retweet function from tweets.ts + await retweet(tweetId, this.auth); + } + + /** + * Follows a user with the given user ID. + * @param userId The user ID of the user to follow. + * @returns A promise that resolves when the user is followed. + */ + public async followUser(userName: string): Promise { + // Call the followUser function from relationships.ts + await followUser(userName, this.auth); + } + private getAuthOptions(): Partial { return { fetch: this.options?.fetch, diff --git a/src/tweets.test.ts b/src/tweets.test.ts index 82e2c19..5d1d0a3 100644 --- a/src/tweets.test.ts +++ b/src/tweets.test.ts @@ -444,18 +444,153 @@ test('scraper can create a poll with sendTweetV2', async () => { ); }); +test('scraper can send a tweet without media', async () => { + const scraper = await getScraper(); + const draftText = 'Test tweet without media ' + Date.now().toString(); + + // Send a tweet without any media attachments + const result = await scraper.sendTweet(draftText); + + // Log and verify the result + console.log('Send tweet without media result:', result); + expect(result.ok).toBeTruthy(); +}, 30000); + +test('scraper can send a tweet with image and video', async () => { + const scraper = await getScraper(); + const draftText = 'Test tweet with image and video ' + Date.now().toString(); + + // Read test image and video files from the test-assets directory + const imageBuffer = fs.readFileSync( + path.join(__dirname, '../test-assets/test-image.jpeg') + ); + const videoBuffer = fs.readFileSync( + path.join(__dirname, '../test-assets/test-video.mp4') + ); + + // Prepare media data array with both image and video + const mediaData = [ + { data: imageBuffer, mediaType: 'image/jpeg' }, + { data: videoBuffer, mediaType: 'video/mp4' }, + ]; + + // Send a tweet with both image and video attachments + const result = await scraper.sendTweet(draftText, undefined, mediaData); + + // Log and verify the result + console.log('Send tweet with image and video result:', result); + expect(result.ok).toBeTruthy(); +}, 30000); + +test('scraper can quote tweet without media', async () => { + const scraper = await getScraper(); + const quotedTweetId = '1776276954435481937'; + const quoteText = `Testing quote tweet without media ${Date.now()}`; + + // Send a quote tweet without any media attachments + const response = await scraper.sendQuoteTweet(quoteText, quotedTweetId); + + // Log and verify the response + console.log('Quote tweet without media result:', response); + expect(response.ok).toBeTruthy(); +}, 30000); + +test('scraper can quote tweet with image and video', async () => { + const scraper = await getScraper(); + const quotedTweetId = '1776276954435481937'; + const quoteText = `Testing quote tweet with image and video ${Date.now()}`; + + // Read test image and video files from the test-assets directory + const imageBuffer = fs.readFileSync( + path.join(__dirname, '../test-assets/test-image.jpeg') + ); + const videoBuffer = fs.readFileSync( + path.join(__dirname, '../test-assets/test-video.mp4') + ); + + // Prepare media data array with both image and video + const mediaData = [ + { data: imageBuffer, mediaType: 'image/jpeg' }, + { data: videoBuffer, mediaType: 'video/mp4' }, + ]; + + // Send a quote tweet with both image and video attachments + const response = await scraper.sendQuoteTweet(quoteText, quotedTweetId, { + mediaData: mediaData, + }); + + // Log and verify the response + console.log('Quote tweet with image and video result:', response); + expect(response.ok).toBeTruthy(); +}, 30000); + +test('scraper can quote tweet with media', async () => { + const scraper = await getScraper(); + const quotedTweetId = '1776276954435481937'; + const quoteText = `Testing quote tweet with media ${Date.now()}`; + + // Read test image file + const imageBuffer = fs.readFileSync( + path.join(__dirname, '../test-assets/test-image.jpeg') + ); + + // Prepare media data with the image + const mediaData = [ + { data: imageBuffer, mediaType: 'image/jpeg' }, + ]; + + // Send a quote tweet with the image attachment + const response = await scraper.sendQuoteTweet(quoteText, quotedTweetId, { + mediaData: mediaData, + }); + + // Log and verify the response + console.log('Quote tweet with media result:', response); + expect(response.ok).toBeTruthy(); +}, 30000); + test('sendTweetWithMedia successfully sends a tweet with media', async () => { const scraper = await getScraper(); const draftText = 'Test tweet with media ' + Date.now().toString(); // Read a test image file - const imageBuffer = fs.readFileSync( - path.join(__dirname, '../test-assets/test-image.jpeg'), + path.join(__dirname, '../test-assets/test-image.jpeg') ); - const result = await scraper.sendTweetWithMedia(draftText, [imageBuffer]); + // Prepare media data with the image + const mediaData = [ + { data: imageBuffer, mediaType: 'image/jpeg' }, + ]; + + // Send a tweet with the image attachment + const result = await scraper.sendTweet(draftText, undefined, mediaData); + // Log and verify the result console.log('Send tweet with media result:', result); expect(result.ok).toBeTruthy(); }, 30000); + +test('scraper can like a tweet', async () => { + const scraper = await getScraper(); + const tweetId = '1776276954435481937'; // Use a real tweet ID for testing + + // Test should not throw an error + await expect(scraper.likeTweet(tweetId)).resolves.not.toThrow(); +}); + +test('scraper can retweet', async () => { + const scraper = await getScraper(); + const tweetId = '1776276954435481937'; + + // Test should not throw an error + await expect(scraper.retweet(tweetId)).resolves.not.toThrow(); +}); + +test('scraper can follow user', async () => { + const scraper = await getScraper(); + const username = 'elonmusk'; // Use a real username for testing + + // Test should not throw an error + await expect(scraper.followUser(username)).resolves.not.toThrow(); +}, 30000); \ No newline at end of file diff --git a/src/tweets.ts b/src/tweets.ts index c46dc49..02e2b40 100644 --- a/src/tweets.ts +++ b/src/tweets.ts @@ -443,7 +443,7 @@ export async function createCreateTweetRequest( text: string, auth: TwitterAuth, tweetId?: string, - mediaData?: Buffer[], + mediaData?: { data: Buffer; mediaType: string }[], ) { const onboardingTaskUrl = 'https://api.twitter.com/1.1/onboarding/task.json'; @@ -476,7 +476,7 @@ export async function createCreateTweetRequest( if (mediaData && mediaData.length > 0) { const mediaIds = await Promise.all( - mediaData.map((data) => uploadMedia(data, auth)), + mediaData.map(({ data, mediaType }) => uploadMedia(data, auth, mediaType)), ); variables.media.media_entities = mediaIds.map((id) => ({ @@ -886,33 +886,356 @@ interface MediaUploadResponse { async function uploadMedia( mediaData: Buffer, auth: TwitterAuth, + mediaType: string, ): Promise { - const onboardingTaskUrl = 'https://upload.twitter.com/1.1/media/upload.json'; + const uploadUrl = 'https://upload.twitter.com/1.1/media/upload.json'; - const cookies = await auth.cookieJar().getCookies(onboardingTaskUrl); + // Get authentication headers + const cookies = await auth.cookieJar().getCookies(uploadUrl); const xCsrfToken = cookies.find((cookie) => cookie.key === 'ct0'); + const headers = new Headers({ + authorization: `Bearer ${(auth as any).bearerToken}`, + cookie: await auth.cookieJar().getCookieString(uploadUrl), + 'x-csrf-token': xCsrfToken?.value as string, + }); + + // Detect if media is a video based on mediaType + const isVideo = mediaType.startsWith('video/'); + + if (isVideo) { + // Handle video upload using chunked media upload + const mediaId = await uploadVideoInChunks(mediaData, mediaType); + return mediaId; + } else { + // Handle image upload + const form = new FormData(); + form.append('media', new Blob([mediaData])); + + const response = await fetch(uploadUrl, { + method: 'POST', + headers, + body: form, + }); + + await updateCookieJar(auth.cookieJar(), response.headers); + + if (!response.ok) { + throw new Error(await response.text()); + } + + const data: MediaUploadResponse = await response.json(); + return data.media_id_string; + } + + // Function to upload video in chunks + async function uploadVideoInChunks(mediaData: Buffer, mediaType: string): Promise { + // Initialize upload + const initParams = new URLSearchParams(); + initParams.append('command', 'INIT'); + initParams.append('media_type', mediaType); + initParams.append('total_bytes', mediaData.length.toString()); + + const initResponse = await fetch(uploadUrl, { + method: 'POST', + headers, + body: initParams, + }); + + if (!initResponse.ok) { + throw new Error(await initResponse.text()); + } + + const initData = await initResponse.json(); + const mediaId = initData.media_id_string; + + // Append upload in chunks + const segmentSize = 5 * 1024 * 1024; // 5 MB per chunk + let segmentIndex = 0; + for (let offset = 0; offset < mediaData.length; offset += segmentSize) { + const chunk = mediaData.slice(offset, offset + segmentSize); + + const appendForm = new FormData(); + appendForm.append('command', 'APPEND'); + appendForm.append('media_id', mediaId); + appendForm.append('segment_index', segmentIndex.toString()); + appendForm.append('media', new Blob([chunk])); + + const appendResponse = await fetch(uploadUrl, { + method: 'POST', + headers, + body: appendForm, + }); + + if (!appendResponse.ok) { + throw new Error(await appendResponse.text()); + } + + segmentIndex++; + } + + // Finalize upload + const finalizeParams = new URLSearchParams(); + finalizeParams.append('command', 'FINALIZE'); + finalizeParams.append('media_id', mediaId); + + const finalizeResponse = await fetch(uploadUrl, { + method: 'POST', + headers, + body: finalizeParams, + }); + + if (!finalizeResponse.ok) { + throw new Error(await finalizeResponse.text()); + } + + const finalizeData = await finalizeResponse.json(); + + // Check processing status for videos + if (finalizeData.processing_info) { + await checkUploadStatus(mediaId); + } + + return mediaId; + } + + // Function to check upload status + async function checkUploadStatus(mediaId: string): Promise { + let processing = true; + while (processing) { + await new Promise((resolve) => setTimeout(resolve, 5000)); // Wait 5 seconds + + const statusParams = new URLSearchParams(); + statusParams.append('command', 'STATUS'); + statusParams.append('media_id', mediaId); + + const statusResponse = await fetch(`${uploadUrl}?${statusParams.toString()}`, { + method: 'GET', + headers, + }); + + if (!statusResponse.ok) { + throw new Error(await statusResponse.text()); + } + + const statusData = await statusResponse.json(); + const state = statusData.processing_info.state; - const form = new FormData(); - form.append('media', new Blob([mediaData])); + if (state === 'succeeded') { + processing = false; + } else if (state === 'failed') { + throw new Error('Video processing failed'); + } + } + } +} + +// Function to create a quote tweet +export async function createQuoteTweetRequest( + text: string, + quotedTweetId: string, + auth: TwitterAuth, + mediaData?: { data: Buffer; mediaType: string }[], +) { + const onboardingTaskUrl = 'https://api.twitter.com/1.1/onboarding/task.json'; + + // Retrieve necessary cookies and tokens + const cookies = await auth.cookieJar().getCookies(onboardingTaskUrl); + const xCsrfToken = cookies.find((cookie) => cookie.key === 'ct0'); const headers = new Headers({ authorization: `Bearer ${(auth as any).bearerToken}`, cookie: await auth.cookieJar().getCookieString(onboardingTaskUrl), + 'content-type': 'application/json', + 'User-Agent': + 'Mozilla/5.0 (Linux; Android 11; Nokia G20) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.88 Mobile Safari/537.36', + 'x-guest-token': (auth as any).guestToken, + 'x-twitter-auth-type': 'OAuth2Client', + 'x-twitter-active-user': 'yes', 'x-csrf-token': xCsrfToken?.value as string, }); - const response = await fetch(onboardingTaskUrl, { + // Construct variables for the GraphQL request + const variables: Record = { + tweet_text: text, + dark_request: false, + attachment_url: `https://twitter.com/twitter/status/${quotedTweetId}`, + media: { + media_entities: [], + possibly_sensitive: false, + }, + semantic_annotation_ids: [], + }; + + // Handle media uploads if any media data is provided + if (mediaData && mediaData.length > 0) { + const mediaIds = await Promise.all( + mediaData.map(({ data, mediaType }) => uploadMedia(data, auth, mediaType)), + ); + + variables.media.media_entities = mediaIds.map((id) => ({ + media_id: id, + tagged_users: [], + })); + } + + // Send the GraphQL request to create a quote tweet + const response = await fetch( + 'https://twitter.com/i/api/graphql/a1p9RWpkYKBjWv_I3WzS-A/CreateTweet', + { + headers, + body: JSON.stringify({ + variables, + features: { + interactive_text_enabled: true, + longform_notetweets_inline_media_enabled: false, + responsive_web_text_conversations_enabled: false, + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: false, + vibe_api_enabled: false, + rweb_lists_timeline_redesign_enabled: true, + responsive_web_graphql_exclude_directive_enabled: true, + verified_phone_label_enabled: false, + creator_subscriptions_tweet_preview_api_enabled: true, + responsive_web_graphql_timeline_navigation_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + tweetypie_unmention_optimization_enabled: true, + responsive_web_edit_tweet_api_enabled: true, + graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, + view_counts_everywhere_api_enabled: true, + longform_notetweets_consumption_enabled: true, + tweet_awards_web_tipping_enabled: false, + freedom_of_speech_not_reach_fetch_enabled: true, + standardized_nudges_misinfo: true, + longform_notetweets_rich_text_read_enabled: true, + responsive_web_enhance_cards_enabled: false, + subscriptions_verification_info_enabled: true, + subscriptions_verification_info_reason_enabled: true, + subscriptions_verification_info_verified_since_enabled: true, + super_follow_badge_privacy_enabled: false, + super_follow_exclusive_tweet_notifications_enabled: false, + super_follow_tweet_api_enabled: false, + super_follow_user_api_enabled: false, + android_graphql_skip_api_media_color_palette: false, + creator_subscriptions_subscription_count_enabled: false, + blue_business_profile_image_shape_enabled: false, + unified_cards_ad_metadata_container_dynamic_card_content_query_enabled: false, + rweb_video_timestamps_enabled: true, + c9s_tweet_anatomy_moderator_badge_enabled: true, + responsive_web_twitter_article_tweet_consumption_enabled: false + }, + fieldToggles: {}, + }), + method: 'POST', + }, + ); + + // Update the cookie jar with any new cookies from the response + await updateCookieJar(auth.cookieJar(), response.headers); + + // Check for errors in the response + if (!response.ok) { + throw new Error(await response.text()); + } + + return response; +} + +/** + * Likes a tweet with the given tweet ID. + * @param tweetId The ID of the tweet to like. + * @param auth The authentication object. + * @returns A promise that resolves when the tweet is liked. + */ +export async function likeTweet( + tweetId: string, + auth: TwitterAuth, +): Promise { + // Prepare the GraphQL endpoint and payload + const likeTweetUrl = + 'https://twitter.com/i/api/graphql/lI07N6Otwv1PhnEgXILM7A/FavoriteTweet'; + + // Retrieve necessary cookies and tokens + const cookies = await auth.cookieJar().getCookies(likeTweetUrl); + const xCsrfToken = cookies.find((cookie) => cookie.key === 'ct0'); + + const headers = new Headers({ + authorization: `Bearer ${(auth as any).bearerToken}`, + cookie: await auth.cookieJar().getCookieString(likeTweetUrl), + 'content-type': 'application/json', + 'x-guest-token': (auth as any).guestToken, + 'x-twitter-auth-type': 'OAuth2Client', + 'x-twitter-active-user': 'yes', + 'x-csrf-token': xCsrfToken?.value as string, + }); + + const payload = { + variables: { + tweet_id: tweetId, + }, + }; + + // Send the POST request to like the tweet + const response = await fetch(likeTweetUrl, { method: 'POST', headers, - body: form, + body: JSON.stringify(payload), }); + // Update the cookie jar with any new cookies from the response await updateCookieJar(auth.cookieJar(), response.headers); + // Check for errors in the response if (!response.ok) { throw new Error(await response.text()); } - - const data: MediaUploadResponse = await response.json(); - return data.media_id_string; } + +/** + * Retweets a tweet with the given tweet ID. + * @param tweetId The ID of the tweet to retweet. + * @param auth The authentication object. + * @returns A promise that resolves when the tweet is retweeted. + */ +export async function retweet( + tweetId: string, + auth: TwitterAuth, +): Promise { + // Prepare the GraphQL endpoint and payload + const retweetUrl = + 'https://twitter.com/i/api/graphql/ojPdsZsimiJrUGLR1sjUtA/CreateRetweet'; + + // Retrieve necessary cookies and tokens + const cookies = await auth.cookieJar().getCookies(retweetUrl); + const xCsrfToken = cookies.find((cookie) => cookie.key === 'ct0'); + + const headers = new Headers({ + authorization: `Bearer ${(auth as any).bearerToken}`, + cookie: await auth.cookieJar().getCookieString(retweetUrl), + 'content-type': 'application/json', + 'x-guest-token': (auth as any).guestToken, + 'x-twitter-auth-type': 'OAuth2Client', + 'x-twitter-active-user': 'yes', + 'x-csrf-token': xCsrfToken?.value as string, + }); + + const payload = { + variables: { + tweet_id: tweetId, + dark_request: false, + }, + }; + + // Send the POST request to retweet the tweet + const response = await fetch(retweetUrl, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + + // Update the cookie jar with any new cookies from the response + await updateCookieJar(auth.cookieJar(), response.headers); + + // Check for errors in the response + if (!response.ok) { + throw new Error(await response.text()); + } +} \ No newline at end of file diff --git a/test-assets/test-video.mp4 b/test-assets/test-video.mp4 new file mode 100644 index 0000000..d0cf265 Binary files /dev/null and b/test-assets/test-video.mp4 differ