UNPKG

@memberjunction/actions-bizapps-social

Version:

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

718 lines (622 loc) 27.1 kB
import { RegisterClass } from '@memberjunction/global'; import { TikTokBaseAction, TikTokVideo } from '../tiktok-base.action'; import { ActionParam, ActionResultSimple, RunActionParams } from '@memberjunction/actions-base'; import { SocialPost, SearchParams } from '../../../base/base-social.action'; import { BaseAction } from '@memberjunction/actions'; /** * Action to search for videos on TikTok * Note: TikTok API only allows searching within authenticated user's own videos */ @RegisterClass(BaseAction, 'SearchVideosAction') export class SearchVideosAction extends TikTokBaseAction { /** * Search for videos on TikTok */ protected async InternalRunAction(params: RunActionParams): Promise<ActionResultSimple> { const { Params } = params; try { // Initialize OAuth const companyIntegrationId = this.getParamValue(Params, 'CompanyIntegrationID'); if (!companyIntegrationId) { throw new Error('CompanyIntegrationID is required'); } await this.initializeOAuth(companyIntegrationId); // Extract search parameters const query = this.getParamValue(Params, 'Query'); const hashtags = this.getParamValue(Params, 'Hashtags') || []; const startDate = this.getParamValue(Params, 'StartDate'); const endDate = this.getParamValue(Params, 'EndDate'); const minViews = this.getParamValue(Params, 'MinViews'); const minEngagement = this.getParamValue(Params, 'MinEngagement'); const sortBy = this.getParamValue(Params, 'SortBy') || 'date'; const sortOrder = this.getParamValue(Params, 'SortOrder') || 'desc'; const limit = this.getParamValue(Params, 'Limit') || 50; const offset = this.getParamValue(Params, 'Offset') || 0; // Build search params const searchParams: SearchParams = { query, hashtags, startDate: startDate ? new Date(startDate) : undefined, endDate: endDate ? new Date(endDate) : undefined, limit, offset }; // Note about TikTok limitations const apiLimitation = { notice: 'TikTok API only allows searching within your own videos', recommendation: 'For broader search capabilities, use TikTok\'s web interface or mobile app', alternativeApproach: 'This search filters your own video library based on the provided criteria' }; // Get all user videos to search through const allVideos = await this.getUserVideos(); // Apply search filters let filteredVideos = this.filterVideos(allVideos, { query, hashtags, startDate, endDate, minViews, minEngagement }); // Sort results filteredVideos = this.sortVideos(filteredVideos, sortBy, sortOrder); // Apply pagination const paginatedVideos = filteredVideos.slice(offset, offset + limit); // Convert to social posts const socialPosts: SocialPost[] = paginatedVideos.map(video => this.normalizePost(video)); // Analyze search results const searchAnalytics = this.analyzeSearchResults(filteredVideos, paginatedVideos); // Create time-based insights for historical analysis const historicalInsights = this.generateHistoricalInsights(filteredVideos, startDate, endDate); // Generate content insights const contentInsights = this.generateContentInsights(filteredVideos, query, hashtags); // Create summary const summary = { totalResults: filteredVideos.length, returnedResults: paginatedVideos.length, searchCriteria: { query, hashtags, dateRange: startDate && endDate ? { start: startDate, end: endDate, daysSpanned: Math.ceil((new Date(endDate).getTime() - new Date(startDate).getTime()) / (1000 * 60 * 60 * 24)) } : null, filters: { minViews, minEngagement } }, analytics: searchAnalytics, historicalInsights, contentInsights, apiLimitation, pagination: { offset, limit, hasMore: offset + limit < filteredVideos.length, totalPages: Math.ceil(filteredVideos.length / limit) } }; // Update output parameters const outputParams = [...Params]; const videosParam = outputParams.find(p => p.Name === 'Videos'); if (videosParam) videosParam.Value = socialPosts; const summaryParam = outputParams.find(p => p.Name === 'Summary'); if (summaryParam) summaryParam.Value = summary; const rawDataParam = outputParams.find(p => p.Name === 'RawData'); if (rawDataParam) rawDataParam.Value = paginatedVideos; return { Success: true, ResultCode: 'SUCCESS', Message: `Found ${filteredVideos.length} videos matching search criteria (showing ${paginatedVideos.length})`, Params: outputParams }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; return { Success: false, ResultCode: this.isAuthError(error) ? 'INVALID_TOKEN' : 'ERROR', Message: `Failed to search TikTok videos: ${errorMessage}`, Params }; } } /** * Filter videos based on search criteria */ private filterVideos(videos: TikTokVideo[], filters: any): TikTokVideo[] { return videos.filter(video => { // Query filter if (filters.query) { const query = filters.query.toLowerCase(); const matchesQuery = video.title.toLowerCase().includes(query) || video.description.toLowerCase().includes(query); if (!matchesQuery) return false; } // Hashtag filter if (filters.hashtags && filters.hashtags.length > 0) { const videoHashtags = this.extractHashtags(video.description); const hasMatchingHashtag = filters.hashtags.some((tag: string) => { const cleanTag = tag.replace('#', '').toLowerCase(); return videoHashtags.includes(cleanTag); }); if (!hasMatchingHashtag) return false; } // Date range filter if (filters.startDate) { const startTime = new Date(filters.startDate).getTime() / 1000; if (video.create_time < startTime) return false; } if (filters.endDate) { const endTime = new Date(filters.endDate).getTime() / 1000; if (video.create_time > endTime) return false; } // View count filter if (filters.minViews && video.view_count < filters.minViews) { return false; } // Engagement filter if (filters.minEngagement) { const engagement = video.like_count + video.comment_count + video.share_count; if (engagement < filters.minEngagement) return false; } return true; }); } /** * Sort videos based on criteria */ private sortVideos(videos: TikTokVideo[], sortBy: string, order: string): TikTokVideo[] { const sorted = [...videos]; sorted.sort((a, b) => { let aValue: number; let bValue: number; switch (sortBy) { case 'views': aValue = a.view_count; bValue = b.view_count; break; case 'likes': aValue = a.like_count; bValue = b.like_count; break; case 'comments': aValue = a.comment_count; bValue = b.comment_count; break; case 'shares': aValue = a.share_count; bValue = b.share_count; break; case 'engagement': aValue = a.like_count + a.comment_count + a.share_count; bValue = b.like_count + b.comment_count + b.share_count; break; case 'date': default: aValue = a.create_time; bValue = b.create_time; break; } return order === 'desc' ? bValue - aValue : aValue - bValue; }); return sorted; } /** * Analyze search results */ private analyzeSearchResults(allResults: TikTokVideo[], displayedResults: TikTokVideo[]): any { if (allResults.length === 0) { return { totalViews: 0, totalEngagement: 0, averageViews: 0, averageEngagement: 0, topPerformer: null }; } const totalViews = allResults.reduce((sum, v) => sum + v.view_count, 0); const totalEngagement = allResults.reduce((sum, v) => sum + v.like_count + v.comment_count + v.share_count, 0 ); return { totalViews, totalEngagement, averageViews: Math.round(totalViews / allResults.length), averageEngagement: Math.round(totalEngagement / allResults.length), engagementRate: totalViews > 0 ? ((totalEngagement / totalViews) * 100).toFixed(2) + '%' : '0%', topPerformer: allResults.reduce((best, current) => current.view_count > best.view_count ? current : best ), performanceDistribution: this.calculatePerformanceDistribution(allResults) }; } /** * Generate historical insights */ private generateHistoricalInsights(videos: TikTokVideo[], startDate?: string, endDate?: string): any { if (videos.length === 0) return null; // Group videos by time period const timeGroups = this.groupVideosByTimePeriod(videos); // Calculate trends const viewTrend = this.calculateTrend(timeGroups, 'views'); const engagementTrend = this.calculateTrend(timeGroups, 'engagement'); // Find best performing periods const bestPeriods = Object.entries(timeGroups) .map(([period, vids]) => ({ period, totalViews: vids.reduce((sum, v) => sum + v.view_count, 0), videoCount: vids.length })) .sort((a, b) => b.totalViews - a.totalViews) .slice(0, 3); return { timeRange: { start: startDate || new Date(Math.min(...videos.map(v => v.create_time)) * 1000), end: endDate || new Date(Math.max(...videos.map(v => v.create_time)) * 1000) }, totalVideosInPeriod: videos.length, trends: { views: viewTrend, engagement: engagementTrend }, bestPerformingPeriods: bestPeriods, postingFrequency: this.calculatePostingFrequency(videos), seasonalPatterns: this.identifySeasonalPatterns(timeGroups) }; } /** * Generate content insights */ private generateContentInsights(videos: TikTokVideo[], query?: string, hashtags?: string[]): any { if (videos.length === 0) return null; // Analyze content themes const commonHashtags = this.findCommonHashtags(videos); const contentLength = this.analyzeContentLength(videos); return { searchRelevance: { query: query || 'all content', matchingVideos: videos.length, averageRelevanceScore: this.calculateRelevanceScore(videos, query, hashtags) }, contentPatterns: { commonHashtags: commonHashtags.slice(0, 10), averageDescriptionLength: contentLength.average, optimalLength: contentLength.optimal, titlePatterns: this.analyzeTitlePatterns(videos) }, performanceCorrelations: { hashtagsToViews: this.correlateHashtagsToPerformance(videos), lengthToEngagement: contentLength.correlation, timeOfDayImpact: this.analyzeTimeOfDayImpact(videos) } }; } /** * Group videos by time period */ private groupVideosByTimePeriod(videos: TikTokVideo[]): Record<string, TikTokVideo[]> { const groups: Record<string, TikTokVideo[]> = {}; videos.forEach(video => { const date = new Date(video.create_time * 1000); const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; if (!groups[monthKey]) { groups[monthKey] = []; } groups[monthKey].push(video); }); return groups; } /** * Calculate performance trend */ private calculateTrend(timeGroups: Record<string, TikTokVideo[]>, metric: string): string { const periods = Object.keys(timeGroups).sort(); if (periods.length < 2) return 'insufficient_data'; const recentPeriod = periods[periods.length - 1]; const previousPeriod = periods[periods.length - 2]; let recentValue: number; let previousValue: number; if (metric === 'views') { recentValue = timeGroups[recentPeriod].reduce((sum, v) => sum + v.view_count, 0); previousValue = timeGroups[previousPeriod].reduce((sum, v) => sum + v.view_count, 0); } else { recentValue = timeGroups[recentPeriod].reduce((sum, v) => sum + v.like_count + v.comment_count + v.share_count, 0 ); previousValue = timeGroups[previousPeriod].reduce((sum, v) => sum + v.like_count + v.comment_count + v.share_count, 0 ); } const changePercent = previousValue > 0 ? ((recentValue - previousValue) / previousValue) * 100 : 100; if (changePercent > 20) return 'increasing'; if (changePercent < -20) return 'decreasing'; return 'stable'; } /** * Calculate posting frequency */ private calculatePostingFrequency(videos: TikTokVideo[]): any { if (videos.length < 2) return { average: 0, pattern: 'insufficient_data' }; const timestamps = videos.map(v => v.create_time).sort(); const daysBetweenPosts: number[] = []; for (let i = 1; i < timestamps.length; i++) { const daysDiff = (timestamps[i] - timestamps[i-1]) / (60 * 60 * 24); daysBetweenPosts.push(daysDiff); } const avgDaysBetween = daysBetweenPosts.reduce((sum, days) => sum + days, 0) / daysBetweenPosts.length; let pattern: string; if (avgDaysBetween < 1) pattern = 'multiple_daily'; else if (avgDaysBetween <= 1.5) pattern = 'daily'; else if (avgDaysBetween <= 3.5) pattern = 'every_few_days'; else if (avgDaysBetween <= 7.5) pattern = 'weekly'; else pattern = 'irregular'; return { averageDaysBetween: avgDaysBetween.toFixed(1), pattern, postsPerWeek: (7 / avgDaysBetween).toFixed(1) }; } /** * Find common hashtags */ private findCommonHashtags(videos: TikTokVideo[]): any[] { const hashtagCount = new Map<string, number>(); videos.forEach(video => { const hashtags = this.extractHashtags(video.description); hashtags.forEach(tag => { hashtagCount.set(tag, (hashtagCount.get(tag) || 0) + 1); }); }); return Array.from(hashtagCount.entries()) .map(([tag, count]) => ({ hashtag: `#${tag}`, count, percentage: (count / videos.length * 100).toFixed(1) })) .sort((a, b) => b.count - a.count); } /** * Analyze content length */ private analyzeContentLength(videos: TikTokVideo[]): any { const lengths = videos.map(v => (v.description || '').length); const avgLength = lengths.reduce((sum, len) => sum + len, 0) / lengths.length; // Find optimal length (videos with above-average performance) const avgViews = videos.reduce((sum, v) => sum + v.view_count, 0) / videos.length; const highPerformers = videos.filter(v => v.view_count > avgViews); const optimalLength = highPerformers.length > 0 ? highPerformers.reduce((sum, v) => sum + (v.description || '').length, 0) / highPerformers.length : avgLength; return { average: Math.round(avgLength), optimal: Math.round(optimalLength), correlation: optimalLength > avgLength ? 'longer_performs_better' : 'shorter_performs_better' }; } /** * Calculate relevance score */ private calculateRelevanceScore(videos: TikTokVideo[], query?: string, hashtags?: string[]): number { if (!query && (!hashtags || hashtags.length === 0)) return 100; let totalScore = 0; videos.forEach(video => { let score = 0; if (query) { const queryLower = query.toLowerCase(); if (video.title.toLowerCase().includes(queryLower)) score += 50; if (video.description.toLowerCase().includes(queryLower)) score += 30; } if (hashtags && hashtags.length > 0) { const videoHashtags = this.extractHashtags(video.description); const matchingHashtags = hashtags.filter(tag => videoHashtags.includes(tag.replace('#', '').toLowerCase()) ); score += (matchingHashtags.length / hashtags.length) * 20; } totalScore += score; }); return videos.length > 0 ? Math.round(totalScore / videos.length) : 0; } /** * Analyze title patterns */ private analyzeTitlePatterns(videos: TikTokVideo[]): any { const patterns = { questions: videos.filter(v => v.title.includes('?')).length, exclamations: videos.filter(v => v.title.includes('!')).length, emojis: videos.filter(v => /[\u{1F600}-\u{1F64F}]/u.test(v.title)).length, numbers: videos.filter(v => /\d/.test(v.title)).length, allCaps: videos.filter(v => v.title === v.title.toUpperCase() && v.title.length > 3).length }; return { ...patterns, mostCommonPattern: Object.entries(patterns) .sort(([, a], [, b]) => b - a)[0][0] }; } /** * Correlate hashtags to performance */ private correlateHashtagsToPerformance(videos: TikTokVideo[]): any[] { const hashtagPerformance = new Map<string, { views: number; count: number }>(); videos.forEach(video => { const hashtags = this.extractHashtags(video.description); hashtags.forEach(tag => { const current = hashtagPerformance.get(tag) || { views: 0, count: 0 }; current.views += video.view_count; current.count += 1; hashtagPerformance.set(tag, current); }); }); return Array.from(hashtagPerformance.entries()) .map(([tag, data]) => ({ hashtag: `#${tag}`, averageViews: Math.round(data.views / data.count), usageCount: data.count })) .sort((a, b) => b.averageViews - a.averageViews) .slice(0, 5); } /** * Analyze time of day impact */ private analyzeTimeOfDayImpact(videos: TikTokVideo[]): any { const hourBuckets: Record<number, { views: number; count: number }> = {}; videos.forEach(video => { const hour = new Date(video.create_time * 1000).getHours(); if (!hourBuckets[hour]) { hourBuckets[hour] = { views: 0, count: 0 }; } hourBuckets[hour].views += video.view_count; hourBuckets[hour].count += 1; }); const hourlyPerformance = Object.entries(hourBuckets) .map(([hour, data]) => ({ hour: parseInt(hour), averageViews: Math.round(data.views / data.count), postCount: data.count })) .sort((a, b) => b.averageViews - a.averageViews); return { bestHours: hourlyPerformance.slice(0, 3).map(h => h.hour), worstHours: hourlyPerformance.slice(-3).map(h => h.hour), recommendation: this.generateTimeRecommendation(hourlyPerformance) }; } /** * Generate time posting recommendation */ private generateTimeRecommendation(hourlyPerformance: any[]): string { if (hourlyPerformance.length === 0) return 'Insufficient data for recommendations'; const bestHour = hourlyPerformance[0].hour; const timeOfDay = bestHour < 12 ? 'morning' : bestHour < 17 ? 'afternoon' : 'evening'; return `Best posting time appears to be ${bestHour}:00 (${timeOfDay}) based on historical performance`; } /** * Calculate performance distribution */ private calculatePerformanceDistribution(videos: TikTokVideo[]): any { const viewBuckets = { '0-1k': 0, '1k-10k': 0, '10k-100k': 0, '100k-1M': 0, '1M+': 0 }; videos.forEach(video => { const views = video.view_count; if (views < 1000) viewBuckets['0-1k']++; else if (views < 10000) viewBuckets['1k-10k']++; else if (views < 100000) viewBuckets['10k-100k']++; else if (views < 1000000) viewBuckets['100k-1M']++; else viewBuckets['1M+']++; }); return viewBuckets; } /** * Identify seasonal patterns */ private identifySeasonalPatterns(timeGroups: Record<string, TikTokVideo[]>): string[] { const patterns: string[] = []; // Simple seasonal analysis const monthlyAvgs: Record<number, number[]> = {}; Object.entries(timeGroups).forEach(([period, videos]) => { const month = parseInt(period.split('-')[1]); if (!monthlyAvgs[month]) monthlyAvgs[month] = []; const avgViews = videos.reduce((sum, v) => sum + v.view_count, 0) / videos.length; monthlyAvgs[month].push(avgViews); }); // Find high-performing months const monthPerformance = Object.entries(monthlyAvgs) .map(([month, avgs]) => ({ month: parseInt(month), avgPerformance: avgs.reduce((sum, avg) => sum + avg, 0) / avgs.length })) .sort((a, b) => b.avgPerformance - a.avgPerformance); if (monthPerformance.length > 0) { const bestMonth = monthPerformance[0].month; const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; patterns.push(`${monthNames[bestMonth - 1]} shows strongest performance historically`); } return patterns; } /** * Define the parameters this action expects */ public get Params(): ActionParam[] { return [ ...this.commonSocialParams, { Name: 'Query', Type: 'Input', Value: null }, { Name: 'Hashtags', Type: 'Input', Value: [] }, { Name: 'StartDate', Type: 'Input', Value: null }, { Name: 'EndDate', Type: 'Input', Value: null }, { Name: 'MinViews', Type: 'Input', Value: null }, { Name: 'MinEngagement', Type: 'Input', Value: null }, { Name: 'SortBy', Type: 'Input', Value: 'date' }, { Name: 'SortOrder', Type: 'Input', Value: 'desc' }, { Name: 'Limit', Type: 'Input', Value: 50 }, { Name: 'Offset', Type: 'Input', Value: 0 }, { Name: 'Videos', Type: 'Output', Value: null }, { Name: 'Summary', Type: 'Output', Value: null }, { Name: 'RawData', Type: 'Output', Value: null } ]; } /** * Metadata about this action */ public get Description(): string { return 'Searches historical TikTok videos with advanced filtering, date ranges, and performance analytics (searches within your own videos only due to API limitations)'; } }