@memberjunction/actions-bizapps-social
Version:
Social Media Actions for MemberJunction - Twitter, LinkedIn, Facebook, Instagram, TikTok, YouTube, HootSuite, Buffer
357 lines (313 loc) • 12.4 kB
text/typescript
import { RegisterClass } from '@memberjunction/global';
import { BaseSocialMediaAction, SocialPost, SocialAnalytics, MediaFile } from '../../base/base-social.action';
import { LogError, LogStatus } from '@memberjunction/core';
import axios, { AxiosInstance, AxiosError } from 'axios';
import { BaseAction } from '@memberjunction/actions';
/**
* TikTok video information
*/
export interface TikTokVideo {
id: string;
share_url: string;
title: string;
description: string;
duration: number;
cover_image_url: string;
share_count: number;
view_count: number;
like_count: number;
comment_count: number;
create_time: number;
}
/**
* TikTok user information
*/
export interface TikTokUser {
open_id: string;
union_id: string;
avatar_url: string;
display_name: string;
bio_description: string;
profile_deep_link: string;
is_verified: boolean;
follower_count: number;
following_count: number;
likes_count: number;
}
/**
* Base class for all TikTok social media actions.
* Handles TikTok-specific authentication and API interaction patterns.
*/
(BaseAction, 'TikTokBaseAction')
export abstract class TikTokBaseAction extends BaseSocialMediaAction {
protected get platformName(): string {
return 'TikTok';
}
protected get apiBaseUrl(): string {
return 'https://open-api.tiktok.com';
}
private axiosInstance: AxiosInstance | null = null;
/**
* Initialize axios instance with interceptors
*/
protected getAxiosInstance(): AxiosInstance {
if (!this.axiosInstance) {
this.axiosInstance = axios.create({
baseURL: this.apiBaseUrl,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
// Request interceptor for logging
this.axiosInstance.interceptors.request.use(
(config) => {
this.logApiRequest(config.method?.toUpperCase() || 'GET', config.url || '', config.data);
return config;
},
(error) => {
LogError(`TikTok API Request Error: ${error.message}`);
return Promise.reject(error);
}
);
// Response interceptor for logging and error handling
this.axiosInstance.interceptors.response.use(
(response) => {
this.logApiResponse(response.data);
return response;
},
async (error: AxiosError) => {
if (error.response?.status === 429) {
// Rate limit hit
const retryAfter = error.response.headers['retry-after'];
await this.handleRateLimit(retryAfter ? parseInt(retryAfter) : undefined);
// Retry the request
return this.axiosInstance?.request(error.config!);
}
LogError(`TikTok API Response Error: ${error.message}`);
return Promise.reject(error);
}
);
}
return this.axiosInstance;
}
/**
* Make authenticated TikTok API request
*/
protected async makeTikTokRequest<T>(
endpoint: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
data?: any,
params?: any
): Promise<T> {
const token = this.getAccessToken();
if (!token) {
throw new Error('No access token available for TikTok');
}
const axios = this.getAxiosInstance();
try {
const response = await axios.request<T>({
method,
url: endpoint,
data,
params,
headers: {
'Authorization': `Bearer ${token}`
}
});
return response.data;
} catch (error) {
if (error instanceof AxiosError) {
if (error.response?.status === 401) {
// Token might be expired, try to refresh
await this.refreshAccessToken();
// Retry with new token
const newToken = this.getAccessToken();
const retryResponse = await axios.request<T>({
method,
url: endpoint,
data,
params,
headers: {
'Authorization': `Bearer ${newToken}`
}
});
return retryResponse.data;
}
const errorMessage = error.response?.data?.error?.message || error.message;
throw new Error(`TikTok API error: ${errorMessage}`);
}
throw error;
}
}
/**
* Refresh TikTok access token
*/
protected async refreshAccessToken(): Promise<void> {
const refreshToken = this.getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available for TikTok');
}
try {
const response = await axios.post(`${this.apiBaseUrl}/oauth/refresh_token/`, {
client_key: this.getCustomAttribute(2), // Store client key in CustomAttribute2
grant_type: 'refresh_token',
refresh_token: refreshToken
});
const { access_token, refresh_token: newRefreshToken, expires_in } = response.data.data;
// Update stored tokens
await this.updateStoredTokens(access_token, newRefreshToken, expires_in);
LogStatus('TikTok access token refreshed successfully');
} catch (error) {
LogError(`Failed to refresh TikTok access token: ${error}`);
throw new Error('Failed to refresh TikTok access token');
}
}
/**
* Upload media to TikTok (requires special approval)
*/
protected async uploadSingleMedia(file: MediaFile): Promise<string> {
// TikTok video upload requires special approval
// This is a placeholder implementation
throw new Error('TikTok video upload requires special API approval. Please use TikTok Creator Studio for video uploads.');
}
/**
* Search posts (videos) - TikTok only allows searching user's own videos
*/
protected async searchPosts(params: any): Promise<SocialPost[]> {
// TikTok doesn't provide a general search API for security/privacy reasons
// We can only search within a user's own videos
const videos = await this.getUserVideos();
let filtered = videos;
// Apply filters if provided
if (params.query) {
const query = params.query.toLowerCase();
filtered = filtered.filter(video =>
video.title.toLowerCase().includes(query) ||
video.description.toLowerCase().includes(query)
);
}
if (params.hashtags && params.hashtags.length > 0) {
filtered = filtered.filter(video => {
const videoHashtags = this.extractHashtags(video.description);
return params.hashtags.some((tag: string) =>
videoHashtags.includes(tag.toLowerCase())
);
});
}
if (params.startDate) {
const startTime = new Date(params.startDate).getTime() / 1000;
filtered = filtered.filter(video => video.create_time >= startTime);
}
if (params.endDate) {
const endTime = new Date(params.endDate).getTime() / 1000;
filtered = filtered.filter(video => video.create_time <= endTime);
}
// Apply limit and offset
if (params.offset) {
filtered = filtered.slice(params.offset);
}
if (params.limit) {
filtered = filtered.slice(0, params.limit);
}
return filtered.map(video => this.normalizePost(video));
}
/**
* Get user's videos
*/
protected async getUserVideos(): Promise<TikTokVideo[]> {
const response = await this.makeTikTokRequest<any>(
'/v2/video/list/',
'GET',
undefined,
{
fields: 'id,share_url,title,description,duration,cover_image_url,share_count,view_count,like_count,comment_count,create_time'
}
);
return response.data?.videos || [];
}
/**
* Convert TikTok video to common post format
*/
protected normalizePost(video: TikTokVideo): SocialPost {
return {
id: video.id,
platform: this.platformName,
profileId: this.getCustomAttribute(1) || '', // Store user ID in CustomAttribute1
content: video.description || video.title,
mediaUrls: [video.cover_image_url],
publishedAt: new Date(video.create_time * 1000),
analytics: {
impressions: video.view_count,
engagements: video.like_count + video.comment_count + video.share_count,
clicks: 0, // Not available in TikTok API
shares: video.share_count,
comments: video.comment_count,
likes: video.like_count,
reach: video.view_count,
videoViews: video.view_count,
platformMetrics: {
duration: video.duration,
shareUrl: video.share_url
}
},
platformSpecificData: {
...video,
videoUrl: video.share_url
}
};
}
/**
* Normalize TikTok analytics to common format
*/
protected normalizeAnalytics(platformData: any): SocialAnalytics {
return {
impressions: platformData.view_count || 0,
engagements: (platformData.like_count || 0) + (platformData.comment_count || 0) + (platformData.share_count || 0),
clicks: 0, // Not available in TikTok API
shares: platformData.share_count || 0,
comments: platformData.comment_count || 0,
likes: platformData.like_count || 0,
reach: platformData.view_count || 0,
videoViews: platformData.view_count || 0,
platformMetrics: platformData
};
}
/**
* Extract hashtags from video description
*/
protected extractHashtags(description: string): string[] {
const hashtagRegex = /#(\w+)/g;
const matches = description.match(hashtagRegex) || [];
return matches.map(tag => tag.substring(1).toLowerCase());
}
/**
* Get current user info
*/
protected async getCurrentUser(): Promise<TikTokUser> {
const response = await this.makeTikTokRequest<any>(
'/v2/user/info/',
'GET',
undefined,
{
fields: 'open_id,union_id,avatar_url,display_name,bio_description,profile_deep_link,is_verified,follower_count,following_count,likes_count'
}
);
return response.data?.user;
}
/**
* Validate TikTok-specific media requirements
*/
protected validateMediaFile(file: MediaFile): void {
const allowedTypes = ['video/mp4', 'video/quicktime', 'video/webm'];
if (!allowedTypes.includes(file.mimeType)) {
throw new Error(`TikTok only supports video files. Got: ${file.mimeType}`);
}
// Max file size: 287.6 MB
const maxSize = 287.6 * 1024 * 1024;
if (file.size > maxSize) {
throw new Error(`File size exceeds TikTok limit of 287.6 MB. Got: ${(file.size / 1024 / 1024).toFixed(2)} MB`);
}
}
}