# social/tasks/tiktok.py from celery import shared_task from django.utils import timezone from apps.social.models import SocialAccount, SocialContent, SocialComment from apps.social.services.tiktok import TikTokService, TikTokAPIError import logging import time logger = logging.getLogger(__name__) def parse_tiktok_timestamp(ts): """Convert TikTok timestamp (seconds) to datetime.""" if not ts: return timezone.now() # TikTok timestamps are usually seconds, check if milliseconds if ts > 1000000000000: ts = ts / 1000 return timezone.make_aware(timezone.datetime.utcfromtimestamp(ts)) @shared_task(bind=True) def extract_all_comments_task(self, account_id): """ FULL SYNC: Fetches ALL Ads and ALL Comments. Used for initial connection or monthly backfill. """ try: account = SocialAccount.objects.get(id=account_id, platform_type='TT') if not account.is_active: return logger.info(f"Starting FULL TikTok Ads sync for {account.name}") page = 1 has_more_ads = True while has_more_ads: try: # 1. Fetch Ads (Content) data = TikTokService.fetch_ads(account, page=page) ads = data.get('list', []) # Check pagination page_info = data.get('page_info', {}) total_ads = page_info.get('total', 0) has_more_ads = (page * 20) < total_ads page += 1 time.sleep(1) for ad_data in ads: ad_id = str(ad_data['ad_id']) # Store Ad as SocialContent content, _ = SocialContent.objects.update_or_create( platform_type='TT', content_id=ad_id, defaults={ 'account': account, 'text': ad_data.get('ad_name', ''), 'created_at': parse_tiktok_timestamp(ad_data.get('create_time')), 'content_data': ad_data } ) # 2. Fetch ALL Comments for this Ad _sync_comments_for_content(account, content, ad_id, full_sync=True) except TikTokAPIError as e: logger.error(f"Error syncing page {page}: {e}") break except Exception as e: logger.error(f"Full Sync Critical Error: {e}") @shared_task def poll_new_comments_task(): """ DELTA SYNC: Fetches comments but stops when existing comments are found. Run frequently (e.g., every 15 mins) via Celery Beat. """ accounts = SocialAccount.objects.filter(platform_type='TT', is_active=True) for account in accounts: # Fetch existing Ads from our DB (we don't re-fetch Ad list every poll to save API calls) contents = SocialContent.objects.filter(account=account, platform_type='TT') for content in contents: try: # Sync comments, but stop early if we hit known data _sync_comments_for_content(account, content, content.content_id, full_sync=False) except TikTokAPIError as e: logger.error(f"Polling Error for Ad {content.content_id}: {e}") def _sync_comments_for_content(account, content, ad_id, full_sync=True): """ Helper function to sync comments for a specific Ad. Args: full_sync (bool): True -> Fetch all pages (History). False -> Stop fetching pages once we find a comment we already have (Delta). """ c_page = 1 has_more_comments = True while has_more_comments: c_data = TikTokService.fetch_comments_for_ad(account, ad_id, page=c_page) comments = c_data.get('list', []) # Check pagination page_info = c_data.get('page_info', {}) total_comments = page_info.get('total', 0) has_more_comments = (c_page * 20) < total_comments c_page += 1 if not comments: break should_stop = False for comm in comments: comm_id = comm.get('comment_id') # DELTA LOGIC: If doing a poll (not full sync), check if exists if not full_sync: exists = SocialComment.objects.filter( platform_type='TT', comment_id=comm_id ).exists() if exists: # We found a comment we already have. # API returns oldest -> newest usually, so we can stop. should_stop = True break # Create or Update comm_time = parse_tiktok_timestamp(comm.get('create_time')) SocialComment.objects.update_or_create( platform_type='TT', comment_id=comm_id, defaults={ 'account': account, 'content': content, 'author_name': comm.get('user_name', 'Unknown'), 'text': comm.get('text', ''), 'created_at': comm_time, 'comment_data': comm } ) if should_stop: break time.sleep(0.5) # Small delay to respect rate limits