| |
| """ |
| TikTok Live Chat Capture - Battle Score Tracker |
| FIXED: Extracts actual scores from LinkMicArmiesEvent |
| NEW: Battle events can be toggled ON/OFF |
| """ |
|
|
| import sys |
| import os |
| import json |
| import site |
| import time |
| import textwrap |
| from datetime import datetime |
|
|
| try: |
| site.addsitedir(site.getusersitepackages()) |
| except: |
| pass |
|
|
| sys.stdout.reconfigure(encoding="utf-8") |
|
|
| |
| try: |
| from TikTokLive import TikTokLiveClient |
| from TikTokLive.events import * |
|
|
| EVENTS_IMPORTED = True |
| except ImportError: |
| EVENTS_IMPORTED = False |
| print("❌ Could not import TikTokLive events") |
|
|
| |
| FONT_SIZE = 16 |
| FONT_NAME = "Arial" |
| WRAP_WIDTH = 40 |
| SIDEBAR_X = 1560 |
| BOTTOM_Y = 1040 |
| LINE_HEIGHT = 38 |
| MAX_LINES_ON_SCREEN = 28 |
| VIDEO_WIDTH = 1920 |
| VIDEO_HEIGHT = 1080 |
|
|
| |
| MAX_RETRIES = 3 |
| RETRY_DELAY = 10 |
|
|
| |
| TRACK_BATTLES = False |
| BATTLE_DEBUG_MODE = False |
|
|
| |
| EMOJI_MAP = { |
| "[laugh]": "😆", |
| "[thanks]": "🙏", |
| "[thumb]": "👍", |
| "[hi]": "👋", |
| "[congrat]": "🎉", |
| "[rockyserious]": "🗿", |
| "[rockyloveit]": "🥰", |
| "[rockycool]": "😎", |
| "[rosiedislike]": "👎", |
| "[rosieawkward]": "😅", |
| "[rosiekisskiss]": "😘", |
| "[rosiecute]": "🥺", |
| "[jolliekissingface]": "😚", |
| "[jolliewow]": "😮", |
| "[jolliespeechless]": "😶", |
| "[jolliesatisfied]": "😌", |
| "[sagethink]": "🤔", |
| "[sagefulfilled]": "😇", |
| "[sageclever]": "🤓", |
| "[sagemoney]": "🤑", |
| } |
| |
|
|
|
|
| def main(): |
| if len(sys.argv) < 4: |
| print("Usage: python chat_capture.py <username> <output_dir> <format>") |
| sys.exit(1) |
|
|
| username = sys.argv[1] |
| output_dir = os.path.normpath(sys.argv[2]) |
| save_format = sys.argv[3] |
|
|
| if not os.path.exists(output_dir): |
| os.makedirs(output_dir, exist_ok=True) |
|
|
| timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S") |
| start_time = datetime.now() |
|
|
| print(f"💬 Chat Capture started for @{username}") |
| print(f"📂 ID: {timestamp_str}") |
| print(f"ℹ️ Battle Tracking: {'✅ ENABLED' if TRACK_BATTLES else '❌ DISABLED'}") |
| if TRACK_BATTLES and BATTLE_DEBUG_MODE: |
| print(f"ℹ️ Battle Debug Mode: ✅ ON (showing participant lists)") |
| elif TRACK_BATTLES: |
| print(f"ℹ️ Battle Debug Mode: ❌ OFF (clean output only)") |
| sys.stdout.flush() |
|
|
| if not EVENTS_IMPORTED: |
| print("❌ Library missing. Please install TikTokLive.") |
| sys.exit(1) |
|
|
| chat_messages = [] |
| last_battle_time = 0 |
| last_scores = {} |
| current_battle_id = None |
| retry_count = 0 |
|
|
| client = TikTokLiveClient(unique_id=f"@{username}") |
|
|
| |
| import logging |
|
|
| logging.getLogger("TikTokLive").setLevel(logging.ERROR) |
|
|
| def get_elapsed(): |
| delta = datetime.now() - start_time |
| return delta.total_seconds() |
|
|
| def replace_tiktok_emojis(text): |
| """Replace TikTok emoji codes with Unicode emojis""" |
| for code, emoji in EMOJI_MAP.items(): |
| text = text.replace(code, emoji) |
| return text |
|
|
| def format_srt_time(seconds): |
| hours = int(seconds // 3600) |
| minutes = int((seconds % 3600) // 60) |
| secs = int(seconds % 60) |
| millis = int((seconds % 1) * 1000) |
| return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}" |
|
|
| def format_ass_time(seconds): |
| hours = int(seconds // 3600) |
| minutes = int((seconds % 3600) // 60) |
| secs = int(seconds % 60) |
| millis = int((seconds % 1) * 100) |
| return f"{hours}:{minutes:02d}:{secs:02d}.{millis:02d}" |
|
|
| def save_chat(): |
| base_filename = f"{username}_{timestamp_str}_chat" |
| file_path_base = os.path.join(output_dir, base_filename) |
|
|
| try: |
| if save_format == "json": |
| with open(file_path_base + ".json", "w", encoding="utf-8") as f: |
| json.dump( |
| {"username": username, "messages": chat_messages}, |
| f, |
| indent=2, |
| ensure_ascii=False, |
| ) |
|
|
| elif save_format == "txt": |
| with open(file_path_base + ".txt", "w", encoding="utf-8") as f: |
| f.write(f"TikTok Chat - @{username} ({timestamp_str})\n\n") |
| for msg in chat_messages: |
| if msg["type"] == "comment": |
| f.write(f"{msg['username']}: {msg['message']}\n") |
| elif msg["type"] == "gift": |
| f.write( |
| f"Gift: {msg['username']} sent {msg['gift_count']}x {msg['gift']}\n" |
| ) |
| elif msg["type"] == "battle": |
| f.write(f"{msg['status']}\n") |
|
|
| elif save_format == "srt": |
| with open(file_path_base + ".srt", "w", encoding="utf-8") as f: |
| counter = 1 |
| for i, msg in enumerate(chat_messages): |
| if msg["type"] not in ["comment", "gift", "battle"]: |
| continue |
| elapsed = msg.get("elapsed_seconds", 0) |
| text = "" |
| if msg["type"] == "comment": |
| text = f"{msg['username']}: {msg['message']}" |
| elif msg["type"] == "gift": |
| text = f"🎁 {msg['username']} sent {msg['gift']}" |
| elif msg["type"] == "battle": |
| text = f"{msg['status']}" |
| text = text.replace("\n", " ") |
|
|
| next_elapsed = elapsed + 3600.0 |
| for j in range(i + 1, len(chat_messages)): |
| if chat_messages[j]["type"] in [ |
| "comment", |
| "gift", |
| "battle", |
| ]: |
| next_elapsed = chat_messages[j].get( |
| "elapsed_seconds", elapsed + 3600.0 |
| ) |
| break |
|
|
| f.write( |
| f"{counter}\n{format_srt_time(elapsed)} --> {format_srt_time(next_elapsed)}\n{text}\n\n" |
| ) |
| counter += 1 |
|
|
| elif save_format == "ass": |
| with open(file_path_base + ".ass", "w", encoding="utf-8") as f: |
| f.write(f"""[Script Info] |
| ScriptType: v4.00+ |
| PlayResX: {VIDEO_WIDTH} |
| PlayResY: {VIDEO_HEIGHT} |
| |
| [V4+ Styles] |
| Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding |
| Style: Feed,{FONT_NAME},{FONT_SIZE},&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,1,0,1,10,10,10,1 |
| |
| [Events] |
| Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text |
| """) |
| screen_states = [] |
| current_buffer = [] |
| valid_messages = [ |
| m |
| for m in chat_messages |
| if m["type"] in ["comment", "gift", "battle"] |
| ] |
|
|
| for i, msg in enumerate(valid_messages): |
| raw_text = "" |
| if msg["type"] == "comment": |
| raw_text = f"{msg['username']}: {msg['message']}" |
| elif msg["type"] == "gift": |
| raw_text = f"🎁 {msg['username']} sent {msg['gift']}" |
| elif msg["type"] == "battle": |
| raw_text = f"{msg['status']}" |
|
|
| wrapped_lines = textwrap.wrap( |
| raw_text.replace("\n", " "), width=WRAP_WIDTH |
| ) |
| current_buffer.extend(wrapped_lines) |
|
|
| while len(current_buffer) > MAX_LINES_ON_SCREEN: |
| current_buffer.pop(0) |
|
|
| start_t = msg.get("elapsed_seconds", 0) |
| if i + 1 < len(valid_messages): |
| end_t = valid_messages[i + 1].get( |
| "elapsed_seconds", start_t + 3600.0 |
| ) |
| if end_t <= start_t: |
| end_t = start_t + 0.1 |
| else: |
| end_t = start_t + 3600.0 |
|
|
| screen_states.append( |
| { |
| "start": start_t, |
| "end": end_t, |
| "lines": list(current_buffer), |
| } |
| ) |
|
|
| for state in screen_states: |
| s_time = format_ass_time(state["start"]) |
| e_time = format_ass_time(state["end"]) |
| for idx, line_text in enumerate(reversed(state["lines"])): |
| y_position = BOTTOM_Y - (idx * LINE_HEIGHT) |
| safe_text = ( |
| line_text.replace(",", "\\,") |
| .replace("{", "(") |
| .replace("}", ")") |
| ) |
| f.write( |
| f"Dialogue: 0,{s_time},{e_time},Feed,,0,0,0,,{{\\pos({SIDEBAR_X},{y_position})}}{safe_text}\n" |
| ) |
|
|
| except Exception as e: |
| print(f"⚠️ Save error: {e}") |
|
|
| def log_battle_event(status_text): |
| nonlocal last_battle_time |
| current_time = time.time() |
|
|
| |
| if current_time - last_battle_time < 0.5: |
| return |
|
|
| last_battle_time = current_time |
|
|
| message = { |
| "timestamp": datetime.now().isoformat(), |
| "type": "battle", |
| "status": status_text, |
| "elapsed_seconds": get_elapsed(), |
| } |
| chat_messages.append(message) |
| print(f"⚔️ {status_text}") |
| sys.stdout.flush() |
| save_chat() |
|
|
| def extract_battle_scores(event): |
| """Extract battle scores from LinkMicArmiesEvent""" |
| try: |
| if not hasattr(event, "armies") or not event.armies: |
| return None |
|
|
| scores = [] |
|
|
| |
| for user_id, army_data in event.armies.items(): |
| if hasattr(army_data, "user_armies") and army_data.user_armies: |
| |
| for user_army in army_data.user_armies: |
| if hasattr(user_army, "score") and hasattr( |
| user_army, "nickname" |
| ): |
| scores.append( |
| { |
| "nickname": user_army.nickname, |
| "score": user_army.score, |
| "user_id": user_army.user_id |
| if hasattr(user_army, "user_id") |
| else None, |
| } |
| ) |
|
|
| return scores if scores else None |
|
|
| except Exception as e: |
| if BATTLE_DEBUG_MODE: |
| print(f"⚠️ Error extracting scores: {e}") |
| return None |
|
|
| @client.on(ConnectEvent) |
| async def on_connect(event): |
| nonlocal retry_count |
| retry_count = 0 |
| print(f"✅ Connected to @{username}") |
| if TRACK_BATTLES: |
| print(f"⚔️ Battle tracking active...") |
| sys.stdout.flush() |
|
|
| @client.on(DisconnectEvent) |
| async def on_disconnect(event): |
| print(f"⚠️ Disconnected from @{username}") |
| save_chat() |
| sys.stdout.flush() |
|
|
| @client.on(CommentEvent) |
| async def on_comment(event): |
| try: |
| user_name = event.user.nickname or event.user.unique_id or "User" |
| comment_text = replace_tiktok_emojis(event.comment) |
|
|
| message = { |
| "timestamp": datetime.now().isoformat(), |
| "type": "comment", |
| "username": user_name, |
| "message": comment_text, |
| "elapsed_seconds": get_elapsed(), |
| } |
| chat_messages.append(message) |
| print(f"💬 {user_name}: {comment_text}") |
| sys.stdout.flush() |
| save_chat() |
| except Exception: |
| pass |
|
|
| @client.on(GiftEvent) |
| async def on_gift(event): |
| try: |
| user_name = event.user.nickname or "Viewer" |
| gift_name = event.gift.name if hasattr(event.gift, "name") else "Gift" |
| count = event.gift.count if hasattr(event.gift, "count") else 1 |
|
|
| message = { |
| "timestamp": datetime.now().isoformat(), |
| "type": "gift", |
| "username": user_name, |
| "gift": gift_name, |
| "gift_count": count, |
| "elapsed_seconds": get_elapsed(), |
| } |
| chat_messages.append(message) |
| print(f"🎁 {user_name} sent {count}x {gift_name}") |
| sys.stdout.flush() |
| save_chat() |
| except Exception: |
| pass |
|
|
| |
| if TRACK_BATTLES: |
| try: |
|
|
| @client.on(LinkMicArmiesEvent) |
| async def on_link_mic_armies(event): |
| nonlocal last_scores |
|
|
| |
| if BATTLE_DEBUG_MODE: |
| print(f"\n{'=' * 60}") |
| print(f"🔍 LinkMicArmiesEvent - Searching for battle scores...") |
|
|
| |
| skip_attrs = { |
| "as_base64", |
| "bytes", |
| "SerializeToString", |
| "FromString", |
| "to_dict", |
| "to_json", |
| "from_dict", |
| "from_json", |
| } |
|
|
| |
| score_attrs = {} |
| for attr in dir(event): |
| if attr.startswith("_") or attr in skip_attrs: |
| continue |
|
|
| try: |
| value = getattr(event, attr) |
| if callable(value): |
| continue |
|
|
| attr_lower = attr.lower() |
| if any( |
| keyword in attr_lower |
| for keyword in [ |
| "score", |
| "point", |
| "total", |
| "battle", |
| "host", |
| "guest", |
| "left", |
| "right", |
| ] |
| ): |
| score_attrs[attr] = value |
| print(f" 🎯 {attr}: {value}") |
| except: |
| pass |
|
|
| |
| if hasattr(event, "battle_settings"): |
| try: |
| print(f" 📋 battle_settings: {event.battle_settings}") |
| except: |
| pass |
|
|
| print(f"{'=' * 60}\n") |
|
|
| scores = extract_battle_scores(event) |
|
|
| if scores and len(scores) >= 2: |
| |
| scores_sorted = sorted( |
| scores, key=lambda x: x["score"], reverse=True |
| ) |
|
|
| user1 = scores_sorted[0] |
| user2 = scores_sorted[1] |
|
|
| |
| score_key = f"{user1['score']}_{user2['score']}" |
|
|
| |
| if last_scores.get("key") != score_key: |
| last_scores = {"key": score_key, "user1": user1, "user2": user2} |
|
|
| status_text = f"BATTLE: {user1['score']} ({user1['nickname']}) VS {user2['score']} ({user2['nickname']})" |
| log_battle_event(status_text) |
|
|
| if BATTLE_DEBUG_MODE: |
| print(f"🔍 Total participants in armies: {len(scores)}") |
| for idx, s in enumerate(scores_sorted[:5], 1): |
| print( |
| f" #{idx}: {s['nickname']} - {s['score']} points" |
| ) |
|
|
| elif BATTLE_DEBUG_MODE and scores: |
| print(f"⚠️ Only {len(scores)} participant(s) found in armies") |
| for s in scores: |
| print(f" • {s['nickname']}: {s['score']}") |
|
|
| except Exception as e: |
| print(f"❌ Could not register LinkMicArmiesEvent handler: {e}") |
|
|
| |
| try: |
|
|
| @client.on(LinkMicBattleEvent) |
| async def on_link_mic_battle(event): |
| nonlocal current_battle_id, last_scores |
|
|
| |
| if hasattr(event, "action") and hasattr(event, "battle_result"): |
| action = str(event.action) if event.action else "" |
|
|
| if "FINISH" in action and event.battle_result: |
| |
| results = [] |
| for user_id, battle_result in event.battle_result.items(): |
| if hasattr(battle_result, "score"): |
| nickname = "Unknown" |
|
|
| |
| if hasattr(event, "anchor_info") and event.anchor_info: |
| for anchor in event.anchor_info: |
| if ( |
| hasattr(anchor, "user_id") |
| and anchor.user_id == user_id |
| ): |
| if hasattr(anchor, "user_info") and hasattr( |
| anchor.user_info, "user" |
| ): |
| if hasattr( |
| anchor.user_info.user, "nick_name" |
| ): |
| nickname = ( |
| anchor.user_info.user.nick_name |
| ) |
| break |
|
|
| results.append( |
| { |
| "user_id": user_id, |
| "nickname": nickname, |
| "score": battle_result.score, |
| "result": str(battle_result.result) |
| if hasattr(battle_result, "result") |
| else "UNKNOWN", |
| } |
| ) |
|
|
| if len(results) >= 2: |
| |
| results_sorted = sorted( |
| results, key=lambda x: x["score"], reverse=True |
| ) |
|
|
| winner = results_sorted[0] |
| loser = results_sorted[1] |
|
|
| status_text = f"BATTLE FINISHED: {winner['score']} ({winner['nickname']}) VS {loser['score']} ({loser['nickname']}) - Winner: {winner['nickname']}" |
| log_battle_event(status_text) |
|
|
| if BATTLE_DEBUG_MODE: |
| print(f"🏆 Final results:") |
| for idx, r in enumerate(results_sorted, 1): |
| print( |
| f" #{idx}: {r['nickname']} - {r['score']} points ({r['result']})" |
| ) |
|
|
| |
| current_battle_id = None |
| last_scores = {} |
|
|
| elif "OPEN" in action: |
| |
| battle_id = getattr(event, "battle_id", None) |
| if battle_id: |
| current_battle_id = battle_id |
| last_scores = {} |
| if BATTLE_DEBUG_MODE: |
| print(f"🔔 New battle starting: {battle_id}") |
| except: |
| if BATTLE_DEBUG_MODE: |
| print("ℹ️ LinkMicBattleEvent not available in this TikTokLive version") |
|
|
| |
| print("✅ Event handlers registered") |
| print("🎯 Monitoring chat...") |
|
|
| while retry_count < MAX_RETRIES: |
| try: |
| print( |
| f"🔌 Connecting to @{username}... (Attempt {retry_count + 1}/{MAX_RETRIES})" |
| ) |
| sys.stdout.flush() |
| client.run() |
| break |
| except KeyboardInterrupt: |
| print("\n🛑 Exit requested...") |
| save_chat() |
| sys.exit(0) |
| except Exception as e: |
| retry_count += 1 |
| print(f"❌ Connection error: {e}") |
| if retry_count < MAX_RETRIES: |
| print(f"⏳ Retrying in {RETRY_DELAY} seconds...") |
| time.sleep(RETRY_DELAY) |
| else: |
| print("❌ Max retries reached. Exiting.") |
| save_chat() |
| sys.exit(1) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|