/**
* Data Fetcher for TikTok Analytics via Lark MCP Proxy
*
* This module provides data fetching capabilities for TikTok video analytics
* from Lark Bitable, with support for both direct API access and MCP proxy.
*/
import type { TikTokVideoData } from './types';
/**
* Configuration for data fetcher
*/
export interface DataFetcherConfig {
/** Lark app token (base ID) */
appToken: string;
/** Bitable table ID */
tableId: string;
/** Optional MCP proxy URL */
mcpProxyUrl?: string;
/** Optional API key for direct access */
apiKey?: string;
/** Lark region */
region?: 'sg' | 'cn' | 'us';
/** Maximum number of records to fetch */
pageSize?: number;
/** Enable caching */
enableCache?: boolean;
/** Cache TTL in milliseconds */
cacheTTL?: number;
}
/**
* Bitable record from API
*/
export interface BitableRecord {
record_id: string;
fields: Record<string, any>;
created_time?: number;
last_modified_time?: number;
}
/**
* API response from Bitable
*/
interface BitableResponse {
code: number;
msg: string;
data: {
items: BitableRecord[];
has_more: boolean;
page_token?: string;
total?: number;
};
}
/**
* Field mapping configuration
* Maps to actual Lark Base field names from:
* https://hypelive.sg.larksuite.com/base/C8kmbTsqoa6rBesTKRpl8nV8gHd
*/
export const DEFAULT_FIELD_MAPPING = {
videoId: 'Unique identifier of the video',
title: 'Video description',
views: 'Total video views',
likes: 'Total number of likes the video received',
comments: 'Total number of comments the video received',
shares: 'Total number of times the video was shared',
watchPercent: 'Percentage of video watched completely',
datePublished: 'Date and time the video was published',
duration: 'Video duration in seconds, rounded to three decimal places'
} as const;
/**
* Cache entry
*/
interface CacheEntry {
data: TikTokVideoData[];
timestamp: number;
}
/**
* Data fetcher class
*/
export class TikTokDataFetcher {
private config: Required<DataFetcherConfig>;
private cache: Map<string, CacheEntry> = new Map();
constructor(config: DataFetcherConfig) {
this.config = {
appToken: config.appToken,
tableId: config.tableId,
mcpProxyUrl: config.mcpProxyUrl || '',
apiKey: config.apiKey || '',
region: config.region || 'sg',
pageSize: config.pageSize || 500,
enableCache: config.enableCache ?? true,
cacheTTL: config.cacheTTL || 60000 // 1 minute default
};
}
/**
* Fetch TikTok video data
*/
async fetchData(forceRefresh = false): Promise<TikTokVideoData[]> {
const cacheKey = `${this.config.appToken}:${this.config.tableId}`;
// Check cache first
if (!forceRefresh && this.config.enableCache) {
const cached = this.getFromCache(cacheKey);
if (cached) {
return cached;
}
}
try {
let records: BitableRecord[];
// Try MCP proxy first if available
if (this.config.mcpProxyUrl) {
records = await this.fetchViaMCP();
} else if (this.config.apiKey) {
records = await this.fetchViaAPI();
} else if (typeof window !== 'undefined' && (window as any).bitable) {
records = await this.fetchViaBitableSDK();
} else {
throw new Error('No valid data source available. Provide mcpProxyUrl, apiKey, or run in Lark aPaaS environment.');
}
// Transform records to TikTokVideoData
const data = this.transformRecords(records);
// Cache the result
if (this.config.enableCache) {
this.setCache(cacheKey, data);
}
return data;
} catch (error) {
throw new Error(`Failed to fetch TikTok data: ${(error as Error).message}`);
}
}
/**
* Fetch via MCP proxy
*/
private async fetchViaMCP(): Promise<BitableRecord[]> {
const url = `${this.config.mcpProxyUrl}/bitable/records`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
app_token: this.config.appToken,
table_id: this.config.tableId,
page_size: this.config.pageSize
})
});
if (!response.ok) {
throw new Error(`MCP proxy error: ${response.status} ${response.statusText}`);
}
const result: BitableResponse = await response.json();
if (result.code !== 0) {
throw new Error(`Bitable API error: ${result.msg} (code: ${result.code})`);
}
return result.data.items || [];
}
/**
* Fetch via direct API
*/
private async fetchViaAPI(): Promise<BitableRecord[]> {
const baseUrl = this.getApiBaseUrl();
const url = `${baseUrl}/open-apis/bitable/v1/apps/${this.config.appToken}/tables/${this.config.tableId}/records`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.config.apiKey}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Lark API error: ${response.status} ${response.statusText}`);
}
const result: BitableResponse = await response.json();
if (result.code !== 0) {
throw new Error(`Bitable API error: ${result.msg} (code: ${result.code})`);
}
return result.data.items || [];
}
/**
* Fetch via Bitable SDK (aPaaS environment)
*/
private async fetchViaBitableSDK(): Promise<BitableRecord[]> {
const { bitable } = (window as any);
// Get table instance
const table = await bitable.base.getTable(this.config.tableId);
// Fetch records
const recordList = await table.getRecords({
pageSize: this.config.pageSize
});
return recordList.records || [];
}
/**
* Transform Bitable records to TikTokVideoData
*/
private transformRecords(records: BitableRecord[]): TikTokVideoData[] {
return records
.map(record => this.transformRecord(record))
.filter((item): item is TikTokVideoData => item !== null);
}
/**
* Transform single record
*/
private transformRecord(record: BitableRecord): TikTokVideoData | null {
const fields = record.fields;
// Skip records with missing required fields
if (!fields[DEFAULT_FIELD_MAPPING.videoId] || !fields[DEFAULT_FIELD_MAPPING.title]) {
return null;
}
try {
return {
videoId: String(fields[DEFAULT_FIELD_MAPPING.videoId] || ''),
title: String(fields[DEFAULT_FIELD_MAPPING.title] || 'Untitled'),
views: this.parseNumber(fields[DEFAULT_FIELD_MAPPING.views]),
likes: this.parseNumber(fields[DEFAULT_FIELD_MAPPING.likes]),
comments: this.parseNumber(fields[DEFAULT_FIELD_MAPPING.comments]),
shares: this.parseNumber(fields[DEFAULT_FIELD_MAPPING.shares]),
watchPercent: this.parseNumber(fields[DEFAULT_FIELD_MAPPING.watchPercent]) * 100, // Convert decimal to percentage
datePublished: this.parseDate(fields[DEFAULT_FIELD_MAPPING.datePublished]),
duration: this.parseNumber(fields[DEFAULT_FIELD_MAPPING.duration])
};
} catch (error) {
console.error('Failed to transform record:', record, error);
return null;
}
}
/**
* Parse number field
*/
private parseNumber(value: any): number {
if (typeof value === 'number') return value;
if (typeof value === 'string') {
const parsed = parseFloat(value);
return isNaN(parsed) ? 0 : parsed;
}
return 0;
}
/**
* Parse date field
*/
private parseDate(value: any): string {
if (!value) return new Date().toISOString();
if (typeof value === 'number') {
// Timestamp in milliseconds
return new Date(value).toISOString();
}
if (typeof value === 'string') {
return new Date(value).toISOString();
}
return new Date().toISOString();
}
/**
* Get API base URL based on region
*/
private getApiBaseUrl(): string {
switch (this.config.region) {
case 'cn':
return 'https://open.feishu.cn';
case 'us':
return 'https://open.larksuite.com';
case 'sg':
default:
return 'https://open.larksuite.com';
}
}
/**
* Get data from cache
*/
private getFromCache(key: string): TikTokVideoData[] | null {
const entry = this.cache.get(key);
if (!entry) return null;
const now = Date.now();
if (now - entry.timestamp > this.config.cacheTTL) {
this.cache.delete(key);
return null;
}
return entry.data;
}
/**
* Set data in cache
*/
private setCache(key: string, data: TikTokVideoData[]): void {
this.cache.set(key, {
data,
timestamp: Date.now()
});
}
/**
* Clear cache
*/
clearCache(): void {
this.cache.clear();
}
/**
* Update configuration
*/
updateConfig(config: Partial<DataFetcherConfig>): void {
this.config = {
...this.config,
...config
};
}
}
/**
* Create a data fetcher instance
*/
export function createDataFetcher(config: DataFetcherConfig): TikTokDataFetcher {
return new TikTokDataFetcher(config);
}
/**
* Fetch data using default configuration
*/
export async function fetchTikTokData(
appToken: string,
tableId: string,
options?: Partial<Omit<DataFetcherConfig, 'appToken' | 'tableId'>>
): Promise<TikTokVideoData[]> {
const fetcher = new TikTokDataFetcher({
appToken,
tableId,
...options
});
return fetcher.fetchData();
}