UNPKG

@memberjunction/actions-bizapps-social

Version:

Social Media Actions for MemberJunction - Twitter, LinkedIn, Facebook, Instagram, TikTok, YouTube, HootSuite, Buffer

589 lines (530 loc) 21.2 kB
import { RegisterClass } from '@memberjunction/global'; import { TwitterBaseAction, Tweet, TwitterSearchParams } from '../twitter-base.action'; import { ActionParam, ActionResultSimple, RunActionParams } from '@memberjunction/actions-base'; import { LogStatus, LogError } from '@memberjunction/core'; import { SocialPost, SearchParams } from '../../../base/base-social.action'; import { BaseAction } from '@memberjunction/actions'; /** * Action to search for tweets on Twitter/X with advanced operators and historical data */ @RegisterClass(BaseAction, 'TwitterSearchTweetsAction') export class TwitterSearchTweetsAction extends TwitterBaseAction { /** * Search for tweets on Twitter */ protected async InternalRunAction(params: RunActionParams): Promise<ActionResultSimple> { const { Params, ContextUser } = params; try { // Initialize OAuth const companyIntegrationId = this.getParamValue(Params, 'CompanyIntegrationID'); if (!await this.initializeOAuth(companyIntegrationId)) { throw new Error('Failed to initialize OAuth connection'); } // Extract parameters const query = this.getParamValue(Params, 'Query'); const hashtags = this.getParamValue(Params, 'Hashtags'); const fromUser = this.getParamValue(Params, 'FromUser'); const toUser = this.getParamValue(Params, 'ToUser'); const mentionUser = this.getParamValue(Params, 'MentionUser'); const startDate = this.getParamValue(Params, 'StartDate'); const endDate = this.getParamValue(Params, 'EndDate'); const language = this.getParamValue(Params, 'Language'); const hasMedia = this.getParamValue(Params, 'HasMedia'); const hasLinks = this.getParamValue(Params, 'HasLinks'); const isRetweet = this.getParamValue(Params, 'IsRetweet'); const isReply = this.getParamValue(Params, 'IsReply'); const isQuote = this.getParamValue(Params, 'IsQuote'); const isVerified = this.getParamValue(Params, 'IsVerified'); const minLikes = this.getParamValue(Params, 'MinLikes'); const minRetweets = this.getParamValue(Params, 'MinRetweets'); const minReplies = this.getParamValue(Params, 'MinReplies'); const place = this.getParamValue(Params, 'Place'); const maxResults = this.getParamValue(Params, 'MaxResults') || 100; const sortOrder = this.getParamValue(Params, 'SortOrder') || 'recency'; // Build search query with advanced operators const searchQuery = this.buildAdvancedSearchQuery({ query, hashtags, fromUser, toUser, mentionUser, language, hasMedia, hasLinks, isRetweet, isReply, isQuote, isVerified, minLikes, minRetweets, minReplies, place }); // Validate query if (!searchQuery.trim()) { throw new Error('At least one search parameter must be provided'); } // Twitter search query length limit if (searchQuery.length > 512) { throw new Error(`Search query exceeds Twitter's 512 character limit (current: ${searchQuery.length} characters)`); } // Build search parameters const searchParams: TwitterSearchParams = { query: searchQuery, max_results: Math.min(maxResults, 100), // API limit per request sort_order: sortOrder as 'recency' | 'relevancy' }; // Add date filters if (startDate) { searchParams.start_time = this.formatTwitterDate(startDate); } if (endDate) { searchParams.end_time = this.formatTwitterDate(endDate); } // Perform search LogStatus(`Searching tweets with query: ${searchQuery.substring(0, 100)}${searchQuery.length > 100 ? '...' : ''}`); const tweets = await this.searchTweetsInternal(searchParams, maxResults); // Convert to normalized format const normalizedPosts: SocialPost[] = tweets.map(tweet => this.normalizePost(tweet)); // Analyze search results const analysis = this.analyzeSearchResults(tweets, normalizedPosts); // Update output parameters const outputParams = [...Params]; const postsParam = outputParams.find(p => p.Name === 'Posts'); if (postsParam) postsParam.Value = normalizedPosts; const tweetsParam = outputParams.find(p => p.Name === 'Tweets'); if (tweetsParam) tweetsParam.Value = tweets; const analysisParam = outputParams.find(p => p.Name === 'Analysis'); if (analysisParam) analysisParam.Value = analysis; const actualQueryParam = outputParams.find(p => p.Name === 'ActualQuery'); if (actualQueryParam) actualQueryParam.Value = searchQuery; return { Success: true, ResultCode: 'SUCCESS', Message: `Successfully found ${normalizedPosts.length} tweets matching search criteria`, Params: outputParams }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; return { Success: false, ResultCode: this.getErrorCode(error), Message: `Failed to search tweets: ${errorMessage}`, Params }; } } /** * Build advanced search query with Twitter operators */ private buildAdvancedSearchQuery(params: any): string { const parts: string[] = []; // Basic query if (params.query) { parts.push(params.query); } // Hashtags if (params.hashtags && Array.isArray(params.hashtags) && params.hashtags.length > 0) { const hashtagQuery = params.hashtags .map((tag: string) => tag.startsWith('#') ? tag : `#${tag}`) .join(' OR '); parts.push(`(${hashtagQuery})`); } // User filters if (params.fromUser) { parts.push(`from:${params.fromUser}`); } if (params.toUser) { parts.push(`to:${params.toUser}`); } if (params.mentionUser) { parts.push(`@${params.mentionUser}`); } // Language filter if (params.language) { parts.push(`lang:${params.language}`); } // Media and link filters if (params.hasMedia === true) { parts.push('has:media'); } else if (params.hasMedia === false) { parts.push('-has:media'); } if (params.hasLinks === true) { parts.push('has:links'); } else if (params.hasLinks === false) { parts.push('-has:links'); } // Tweet type filters if (params.isRetweet === true) { parts.push('is:retweet'); } else if (params.isRetweet === false) { parts.push('-is:retweet'); } if (params.isReply === true) { parts.push('is:reply'); } else if (params.isReply === false) { parts.push('-is:reply'); } if (params.isQuote === true) { parts.push('is:quote'); } else if (params.isQuote === false) { parts.push('-is:quote'); } // Verified filter if (params.isVerified === true) { parts.push('is:verified'); } else if (params.isVerified === false) { parts.push('-is:verified'); } // Engagement filters (Note: These require Academic Research access) if (params.minLikes && params.minLikes > 0) { parts.push(`min_faves:${params.minLikes}`); } if (params.minRetweets && params.minRetweets > 0) { parts.push(`min_retweets:${params.minRetweets}`); } if (params.minReplies && params.minReplies > 0) { parts.push(`min_replies:${params.minReplies}`); } // Place filter if (params.place) { parts.push(`place:"${params.place}"`); } return parts.join(' '); } /** * Internal method to search tweets with pagination */ private async searchTweetsInternal(searchParams: TwitterSearchParams, maxResults: number): Promise<Tweet[]> { const tweets: Tweet[] = []; let nextToken: string | undefined; const queryParams: Record<string, any> = { 'query': searchParams.query, 'tweet.fields': 'id,text,created_at,author_id,conversation_id,public_metrics,attachments,entities,referenced_tweets,lang,possibly_sensitive', 'user.fields': 'id,name,username,profile_image_url,description,created_at,verified', 'media.fields': 'url,preview_image_url,type,width,height', 'expansions': 'author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id', 'max_results': searchParams.max_results, 'sort_order': searchParams.sort_order }; // Add time filters if (searchParams.start_time) { queryParams['start_time'] = searchParams.start_time; } if (searchParams.end_time) { queryParams['end_time'] = searchParams.end_time; } while (tweets.length < maxResults) { if (nextToken) { queryParams['next_token'] = nextToken; } try { const response = await this.axiosInstance.get('/tweets/search/recent', { params: queryParams }); if (response.data.data && Array.isArray(response.data.data)) { tweets.push(...response.data.data); } // Check if we've reached the desired number of results if (tweets.length >= maxResults) { return tweets.slice(0, maxResults); } // Check for more pages nextToken = response.data.meta?.next_token; if (!nextToken) { break; } } catch (error) { // If we get a 400 error, it might be due to unsupported operators if ((error as any).response?.status === 400) { const errorDetail = (error as any).response?.data?.detail || ''; if (errorDetail.includes('min_faves') || errorDetail.includes('min_retweets') || errorDetail.includes('min_replies')) { LogStatus('Note: Engagement filters (min_likes, min_retweets, min_replies) require Academic Research access'); // Retry without engagement filters const cleanedQuery = searchParams.query .replace(/min_faves:\d+\s*/g, '') .replace(/min_retweets:\d+\s*/g, '') .replace(/min_replies:\d+\s*/g, '') .trim(); if (cleanedQuery !== searchParams.query) { queryParams['query'] = cleanedQuery; continue; } } } throw error; } } return tweets; } /** * Implement searchPosts for base class */ protected async searchPosts(params: SearchParams): Promise<SocialPost[]> { const searchParams: TwitterSearchParams = { query: this.buildSearchQuery(params), max_results: params.limit || 100 }; if (params.startDate) { searchParams.start_time = this.formatTwitterDate(params.startDate); } if (params.endDate) { searchParams.end_time = this.formatTwitterDate(params.endDate); } const tweets = await this.searchTweetsInternal(searchParams, params.limit || 100); return tweets.map(tweet => this.normalizePost(tweet)); } /** * Analyze search results */ private analyzeSearchResults(tweets: Tweet[], normalizedPosts: SocialPost[]): any { const analysis = { totalResults: tweets.length, dateRange: { earliest: null as string | null, latest: null as string | null }, languages: {} as Record<string, number>, tweetTypes: { original: 0, replies: 0, retweets: 0, quotes: 0 }, topHashtags: [] as Array<{ tag: string; count: number }>, topMentions: [] as Array<{ username: string; count: number }>, engagementStats: { totalLikes: 0, totalRetweets: 0, totalReplies: 0, totalQuotes: 0, averageEngagement: 0 }, topEngagedTweets: [] as any[] }; // Track hashtags and mentions const hashtagCounts = new Map<string, number>(); const mentionCounts = new Map<string, number>(); tweets.forEach((tweet, index) => { // Date range const createdAt = tweet.created_at; if (!analysis.dateRange.earliest || createdAt < analysis.dateRange.earliest) { analysis.dateRange.earliest = createdAt; } if (!analysis.dateRange.latest || createdAt > analysis.dateRange.latest) { analysis.dateRange.latest = createdAt; } // Language const lang = (tweet as any).lang || 'unknown'; analysis.languages[lang] = (analysis.languages[lang] || 0) + 1; // Tweet types if (tweet.referenced_tweets) { const types = tweet.referenced_tweets.map(ref => ref.type); if (types.includes('replied_to')) analysis.tweetTypes.replies++; else if (types.includes('retweeted')) analysis.tweetTypes.retweets++; else if (types.includes('quoted')) analysis.tweetTypes.quotes++; } else { analysis.tweetTypes.original++; } // Hashtags if (tweet.entities?.hashtags) { tweet.entities.hashtags.forEach(hashtag => { const tag = hashtag.tag.toLowerCase(); hashtagCounts.set(tag, (hashtagCounts.get(tag) || 0) + 1); }); } // Mentions if (tweet.entities?.mentions) { tweet.entities.mentions.forEach(mention => { const username = mention.username.toLowerCase(); mentionCounts.set(username, (mentionCounts.get(username) || 0) + 1); }); } // Engagement stats if (tweet.public_metrics) { analysis.engagementStats.totalLikes += tweet.public_metrics.like_count || 0; analysis.engagementStats.totalRetweets += tweet.public_metrics.retweet_count || 0; analysis.engagementStats.totalReplies += tweet.public_metrics.reply_count || 0; analysis.engagementStats.totalQuotes += tweet.public_metrics.quote_count || 0; } }); // Calculate average engagement if (tweets.length > 0) { const totalEngagement = analysis.engagementStats.totalLikes + analysis.engagementStats.totalRetweets + analysis.engagementStats.totalReplies + analysis.engagementStats.totalQuotes; analysis.engagementStats.averageEngagement = Math.round(totalEngagement / tweets.length); } // Top hashtags analysis.topHashtags = Array.from(hashtagCounts.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([tag, count]) => ({ tag, count })); // Top mentions analysis.topMentions = Array.from(mentionCounts.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([username, count]) => ({ username, count })); // Top engaged tweets analysis.topEngagedTweets = normalizedPosts .filter(post => post.analytics) .sort((a, b) => (b.analytics?.engagements || 0) - (a.analytics?.engagements || 0)) .slice(0, 5) .map(post => ({ id: post.id, content: post.content.substring(0, 100) + (post.content.length > 100 ? '...' : ''), engagement: post.analytics?.engagements, metrics: post.analytics })); return analysis; } /** * Get error code based on error type */ private getErrorCode(error: any): string { if (error instanceof Error) { if (error.message.includes('Rate Limit')) return 'RATE_LIMIT'; if (error.message.includes('Unauthorized')) return 'INVALID_TOKEN'; if (error.message.includes('character limit')) return 'QUERY_TOO_LONG'; if (error.message.includes('Academic Research')) return 'INSUFFICIENT_ACCESS'; } return 'ERROR'; } /** * Define the parameters this action expects */ public get Params(): ActionParam[] { return [ ...this.commonSocialParams, { Name: 'Query', Type: 'Input', Value: null }, { Name: 'Hashtags', Type: 'Input', Value: null }, { Name: 'FromUser', Type: 'Input', Value: null }, { Name: 'ToUser', Type: 'Input', Value: null }, { Name: 'MentionUser', Type: 'Input', Value: null }, { Name: 'StartDate', Type: 'Input', Value: null }, { Name: 'EndDate', Type: 'Input', Value: null }, { Name: 'Language', Type: 'Input', Value: null }, { Name: 'HasMedia', Type: 'Input', Value: null }, { Name: 'HasLinks', Type: 'Input', Value: null }, { Name: 'IsRetweet', Type: 'Input', Value: null }, { Name: 'IsReply', Type: 'Input', Value: null }, { Name: 'IsQuote', Type: 'Input', Value: null }, { Name: 'IsVerified', Type: 'Input', Value: null }, { Name: 'MinLikes', Type: 'Input', Value: null }, { Name: 'MinRetweets', Type: 'Input', Value: null }, { Name: 'MinReplies', Type: 'Input', Value: null }, { Name: 'Place', Type: 'Input', Value: null }, { Name: 'MaxResults', Type: 'Input', Value: 100 }, { Name: 'SortOrder', Type: 'Input', Value: 'recency' // 'recency' or 'relevancy' }, { Name: 'Posts', Type: 'Output', Value: null }, { Name: 'Tweets', Type: 'Output', Value: null }, { Name: 'Analysis', Type: 'Output', Value: null }, { Name: 'ActualQuery', Type: 'Output', Value: null } ]; } /** * Get action description */ public get Description(): string { return 'Searches for tweets on Twitter/X using advanced operators and filters, with comprehensive analysis of results including historical data'; } }