UNPKG

@memberjunction/actions-bizapps-social

Version:

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

570 lines (492 loc) 20.5 kB
import { RegisterClass } from '@memberjunction/global'; import { InstagramBaseAction } from '../instagram-base.action'; import { ActionParam, ActionResultSimple, RunActionParams } from '@memberjunction/actions-base'; import { LogError } from '@memberjunction/core'; import { SocialPost, SearchParams } from '../../../base/base-social.action'; import { BaseAction } from '@memberjunction/actions'; /** * Searches for historical Instagram posts from the business account. * Instagram API only allows searching within your own business account's posts. * Supports filtering by date range, hashtags, and content. */ @RegisterClass(BaseAction, 'Instagram - Search Posts') export class InstagramSearchPostsAction extends InstagramBaseAction { protected async InternalRunAction(params: RunActionParams): Promise<ActionResultSimple> { try { const companyIntegrationId = this.getParamValue(params.Params, 'CompanyIntegrationID'); const query = this.getParamValue(params.Params, 'Query'); const hashtags = this.getParamValue(params.Params, 'Hashtags') as string[]; const startDate = this.getParamValue(params.Params, 'StartDate'); const endDate = this.getParamValue(params.Params, 'EndDate'); const mediaType = this.getParamValue(params.Params, 'MediaType'); const minEngagement = this.getParamValue(params.Params, 'MinEngagement') || 0; const limit = this.getParamValue(params.Params, 'Limit') || 100; const includeArchived = this.getParamValue(params.Params, 'IncludeArchived') || false; // Initialize OAuth if (!await this.initializeOAuth(companyIntegrationId)) { return { Success: false, Message: 'Failed to initialize Instagram authentication', ResultCode: 'AUTH_FAILED' }; } // Build search parameters const searchParams: SearchParams = { query, hashtags, startDate: startDate ? new Date(startDate) : undefined, endDate: endDate ? new Date(endDate) : undefined, limit }; // Perform search const posts = await this.searchPosts(searchParams); // Filter by media type if specified let filteredPosts = posts; if (mediaType) { filteredPosts = posts.filter(post => post.platformSpecificData.mediaType === mediaType ); } // Filter by minimum engagement if (minEngagement > 0) { filteredPosts = filteredPosts.filter(post => { const totalEngagement = (post.analytics?.likes || 0) + (post.analytics?.comments || 0); return totalEngagement >= minEngagement; }); } // Include archived posts if requested if (includeArchived) { const archivedPosts = await this.getArchivedPosts(searchParams); filteredPosts = [...filteredPosts, ...archivedPosts]; } // Sort posts by relevance const sortedPosts = this.sortByRelevance(filteredPosts, query, hashtags); // Analyze search results const analysis = this.analyzeSearchResults(sortedPosts, searchParams); // Store result in output params const outputParams = [...params.Params]; outputParams.push({ Name: 'ResultData', Type: 'Output', Value: JSON.stringify({ posts: sortedPosts.slice(0, limit), totalFound: sortedPosts.length, searchCriteria: { query, hashtags, dateRange: { start: startDate, end: endDate }, mediaType, minEngagement }, analysis, suggestions: this.generateSearchSuggestions(sortedPosts, searchParams) }) }); return { Success: true, Message: `Found ${sortedPosts.length} matching posts`, ResultCode: 'SUCCESS', Params: outputParams }; } catch (error: any) { LogError('Failed to search Instagram posts', error); if (error.code === 'RATE_LIMIT') { return { Success: false, Message: 'Instagram API rate limit exceeded. Please try again later.', ResultCode: 'RATE_LIMIT' }; } return { Success: false, Message: `Failed to search posts: ${error.message}`, ResultCode: 'ERROR' }; } } /** * Search posts implementation (Instagram only allows searching own posts) */ protected async searchPosts(params: SearchParams): Promise<SocialPost[]> { // Instagram doesn't have a public search API, so we fetch all posts // and filter them client-side const allPosts = await this.fetchAllAccountPosts(params.startDate, params.endDate); // Filter posts based on search criteria let filtered = allPosts; // Filter by query in caption if (params.query) { const queryLower = params.query.toLowerCase(); filtered = filtered.filter(post => post.content.toLowerCase().includes(queryLower) ); } // Filter by hashtags if (params.hashtags && params.hashtags.length > 0) { const searchHashtags = params.hashtags.map(tag => tag.startsWith('#') ? tag.toLowerCase() : `#${tag}`.toLowerCase() ); filtered = filtered.filter(post => { const postHashtags = this.extractHashtags(post.content); return searchHashtags.some(searchTag => postHashtags.includes(searchTag) ); }); } return filtered.slice(0, params.limit || 100); } /** * Fetch all posts from the account within date range */ private async fetchAllAccountPosts(startDate?: Date, endDate?: Date): Promise<SocialPost[]> { const posts: SocialPost[] = []; let hasNext = true; let afterCursor: string | undefined; while (hasNext) { const queryParams: any = { fields: 'id,caption,media_type,media_url,permalink,timestamp,like_count,comments_count', access_token: this.getAccessToken(), limit: 100 }; if (afterCursor) { queryParams.after = afterCursor; } if (startDate) { queryParams.since = Math.floor(startDate.getTime() / 1000); } if (endDate) { queryParams.until = Math.floor(endDate.getTime() / 1000); } const response = await this.makeInstagramRequest<{ data: any[]; paging?: { cursors: { after: string }; next?: string; }; }>( `${this.instagramBusinessAccountId}/media`, 'GET', null, queryParams ); if (response.data) { const normalizedPosts = response.data.map(post => this.normalizePost(post)); posts.push(...normalizedPosts); } if (response.paging?.next && response.paging?.cursors?.after) { afterCursor = response.paging.cursors.after; } else { hasNext = false; } // Stop if we've reached the date range limit if (posts.length > 0 && startDate) { const oldestPost = posts[posts.length - 1]; if (oldestPost.publishedAt < startDate) { hasNext = false; } } } return posts; } /** * Get archived posts (if any) */ private async getArchivedPosts(params: SearchParams): Promise<SocialPost[]> { try { // Instagram API doesn't have a specific endpoint for archived posts // This would require additional implementation or different API access return []; } catch (error) { LogError('Failed to get archived posts', error); return []; } } /** * Extract hashtags from content */ private extractHashtags(content: string): string[] { const hashtagRegex = /#[\w\u0590-\u05ff]+/g; const matches = content.match(hashtagRegex) || []; return matches.map(tag => tag.toLowerCase()); } /** * Sort posts by relevance */ private sortByRelevance( posts: SocialPost[], query?: string, hashtags?: string[] ): SocialPost[] { return posts.sort((a, b) => { let scoreA = 0; let scoreB = 0; // Score based on query match position if (query) { const queryLower = query.toLowerCase(); const posA = a.content.toLowerCase().indexOf(queryLower); const posB = b.content.toLowerCase().indexOf(queryLower); if (posA === 0) scoreA += 10; // Starts with query else if (posA > 0) scoreA += 5; // Contains query if (posB === 0) scoreB += 10; else if (posB > 0) scoreB += 5; } // Score based on hashtag matches if (hashtags && hashtags.length > 0) { const hashtagsA = this.extractHashtags(a.content); const hashtagsB = this.extractHashtags(b.content); hashtags.forEach(tag => { const searchTag = tag.startsWith('#') ? tag.toLowerCase() : `#${tag}`.toLowerCase(); if (hashtagsA.includes(searchTag)) scoreA += 3; if (hashtagsB.includes(searchTag)) scoreB += 3; }); } // Score based on engagement const engagementA = (a.analytics?.likes || 0) + (a.analytics?.comments || 0); const engagementB = (b.analytics?.likes || 0) + (b.analytics?.comments || 0); scoreA += Math.log10(engagementA + 1); scoreB += Math.log10(engagementB + 1); // Sort by score (descending) then by date (descending) if (scoreA !== scoreB) { return scoreB - scoreA; } return b.publishedAt.getTime() - a.publishedAt.getTime(); }); } /** * Analyze search results */ private analyzeSearchResults(posts: SocialPost[], params: SearchParams): any { const analysis = { totalPosts: posts.length, dateRange: { earliest: null as Date | null, latest: null as Date | null }, mediaTypes: { IMAGE: 0, VIDEO: 0, CAROUSEL_ALBUM: 0, REELS: 0 }, engagement: { totalLikes: 0, totalComments: 0, avgLikesPerPost: 0, avgCommentsPerPost: 0, topPost: null as any }, hashtagFrequency: {} as Record<string, number>, postingPatterns: { byDayOfWeek: {} as Record<string, number>, byHour: {} as Record<number, number> } }; if (posts.length === 0) return analysis; // Find date range const dates = posts.map(p => p.publishedAt.getTime()); analysis.dateRange.earliest = new Date(Math.min(...dates)); analysis.dateRange.latest = new Date(Math.max(...dates)); // Analyze posts let topEngagement = 0; posts.forEach(post => { // Media types const mediaType = post.platformSpecificData.mediaType; if (analysis.mediaTypes[mediaType] !== undefined) { analysis.mediaTypes[mediaType]++; } // Engagement const likes = post.analytics?.likes || 0; const comments = post.analytics?.comments || 0; const totalEngagement = likes + comments; analysis.engagement.totalLikes += likes; analysis.engagement.totalComments += comments; if (totalEngagement > topEngagement) { topEngagement = totalEngagement; analysis.engagement.topPost = { id: post.id, content: post.content.substring(0, 100) + '...', engagement: totalEngagement, publishedAt: post.publishedAt }; } // Hashtags const hashtags = this.extractHashtags(post.content); hashtags.forEach(tag => { analysis.hashtagFrequency[tag] = (analysis.hashtagFrequency[tag] || 0) + 1; }); // Posting patterns const dayOfWeek = post.publishedAt.toLocaleDateString('en-US', { weekday: 'long' }); const hour = post.publishedAt.getHours(); analysis.postingPatterns.byDayOfWeek[dayOfWeek] = (analysis.postingPatterns.byDayOfWeek[dayOfWeek] || 0) + 1; analysis.postingPatterns.byHour[hour] = (analysis.postingPatterns.byHour[hour] || 0) + 1; }); // Calculate averages analysis.engagement.avgLikesPerPost = Math.round(analysis.engagement.totalLikes / posts.length); analysis.engagement.avgCommentsPerPost = Math.round(analysis.engagement.totalComments / posts.length); return analysis; } /** * Generate search suggestions based on results */ private generateSearchSuggestions(posts: SocialPost[], params: SearchParams): any { const suggestions = { relatedHashtags: [] as string[], optimalPostingTimes: [] as any[], contentThemes: [] as string[], performanceInsights: [] as string[] }; if (posts.length === 0) { suggestions.performanceInsights.push('No posts found matching your criteria. Try broadening your search.'); return suggestions; } // Related hashtags (most frequently used) const hashtagCounts: Record<string, number> = {}; posts.forEach(post => { const hashtags = this.extractHashtags(post.content); hashtags.forEach(tag => { if (!params.hashtags?.includes(tag)) { hashtagCounts[tag] = (hashtagCounts[tag] || 0) + 1; } }); }); suggestions.relatedHashtags = Object.entries(hashtagCounts) .sort(([, a], [, b]) => b - a) .slice(0, 10) .map(([tag]) => tag); // Optimal posting times (based on engagement) const timeEngagement: Record<string, { total: number; count: number }> = {}; posts.forEach(post => { const hour = post.publishedAt.getHours(); const day = post.publishedAt.toLocaleDateString('en-US', { weekday: 'long' }); const key = `${day} ${hour}:00`; if (!timeEngagement[key]) { timeEngagement[key] = { total: 0, count: 0 }; } const engagement = (post.analytics?.likes || 0) + (post.analytics?.comments || 0); timeEngagement[key].total += engagement; timeEngagement[key].count++; }); suggestions.optimalPostingTimes = Object.entries(timeEngagement) .map(([time, data]) => ({ time, avgEngagement: Math.round(data.total / data.count) })) .sort((a, b) => b.avgEngagement - a.avgEngagement) .slice(0, 5); // Performance insights const avgEngagement = posts.reduce((sum, post) => sum + (post.analytics?.likes || 0) + (post.analytics?.comments || 0), 0 ) / posts.length; suggestions.performanceInsights.push( `Average engagement per post: ${Math.round(avgEngagement)}`, `Most successful media type: ${this.getMostSuccessfulMediaType(posts)}`, `Posts with questions get ${this.getQuestionEngagementBoost(posts)}% more engagement` ); return suggestions; } /** * Get most successful media type */ private getMostSuccessfulMediaType(posts: SocialPost[]): string { const typeEngagement: Record<string, { total: number; count: number }> = {}; posts.forEach(post => { const type = post.platformSpecificData.mediaType; if (!typeEngagement[type]) { typeEngagement[type] = { total: 0, count: 0 }; } const engagement = (post.analytics?.likes || 0) + (post.analytics?.comments || 0); typeEngagement[type].total += engagement; typeEngagement[type].count++; }); let bestType = 'IMAGE'; let bestAvg = 0; Object.entries(typeEngagement).forEach(([type, data]) => { const avg = data.total / data.count; if (avg > bestAvg) { bestAvg = avg; bestType = type; } }); return bestType; } /** * Calculate engagement boost for posts with questions */ private getQuestionEngagementBoost(posts: SocialPost[]): number { const withQuestions = posts.filter(p => p.content.includes('?')); const withoutQuestions = posts.filter(p => !p.content.includes('?')); if (withQuestions.length === 0 || withoutQuestions.length === 0) { return 0; } const avgWithQuestions = withQuestions.reduce((sum, post) => sum + (post.analytics?.likes || 0) + (post.analytics?.comments || 0), 0 ) / withQuestions.length; const avgWithoutQuestions = withoutQuestions.reduce((sum, post) => sum + (post.analytics?.likes || 0) + (post.analytics?.comments || 0), 0 ) / withoutQuestions.length; if (avgWithoutQuestions === 0) return 0; return Math.round(((avgWithQuestions - avgWithoutQuestions) / avgWithoutQuestions) * 100); } /** * Define the parameters for this action */ public get Params(): ActionParam[] { return [ ...this.commonSocialParams, { Name: 'Query', Type: 'Input', Value: null }, { Name: 'Hashtags', Type: 'Input', Value: null }, { Name: 'StartDate', Type: 'Input', Value: null }, { Name: 'EndDate', Type: 'Input', Value: null }, { Name: 'MediaType', Type: 'Input', Value: null }, { Name: 'MinEngagement', Type: 'Input', Value: 0 }, { Name: 'Limit', Type: 'Input', Value: 100 }, { Name: 'IncludeArchived', Type: 'Input', Value: false } ]; } /** * Get the description for this action */ public get Description(): string { return 'Searches historical Instagram posts from your business account with filters for date range, hashtags, content, and engagement metrics.'; } }