# WhatsApp Chat Viewer # # This script reads a WhatsApp ChatStorage.sqlite database and associated media files # to generate a browsable HTML representation of your chats. # # Author: Gemini # Date: September 7, 2025 # Version: 1.3 - Improved name resolution to avoid displaying encoded strings. import sqlite3 import os import argparse import html from datetime import datetime, timedelta import shutil # WhatsApp's epoch starts on 2001-01-01 00:00:00 (Core Data timestamp) WHATSAPP_EPOCH = datetime(2001, 1, 1) def convert_whatsapp_timestamp(ts): """Converts WhatsApp's Core Data timestamp to a human-readable string.""" if not ts: return "" try: # Timestamps are seconds since the WhatsApp epoch dt = WHATSAPP_EPOCH + timedelta(seconds=ts) return dt.strftime('%Y-%m-%d %H:%M:%S') except (ValueError, TypeError): return "Invalid date" def get_media_tag(media_path, media_root_dir, output_dir): """Generates the appropriate HTML tag for a given media file and copies it.""" if not media_path: return "" # Path in the DB is often relative like 'Media/WhatsApp Images/IMG-...' full_media_path = os.path.join(media_root_dir, os.path.basename(media_path)) # Sometimes the path is nested inside a subdirectory within the main Media folder if not os.path.exists(full_media_path): full_media_path = os.path.join(media_root_dir, media_path) if not os.path.exists(full_media_path): return f'
Media not found: {html.escape(media_path)}
' # Create a unique-ish path to avoid filename collisions relative_media_path = os.path.join('media', os.path.basename(media_path)) dest_path = os.path.join(output_dir, relative_media_path) os.makedirs(os.path.dirname(dest_path), exist_ok=True) if not os.path.exists(dest_path): try: shutil.copy(full_media_path, dest_path) except Exception as e: return f'
Error copying media: {html.escape(str(e))}
' ext = os.path.splitext(media_path)[1].lower() if ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']: return f'Image' elif ext in ['.mp4', '.mov', '.webm']: return f'' elif ext in ['.mp3', '.ogg', '.opus', '.m4a']: return f'' else: return f'View Media: {os.path.basename(media_path)}' def generate_html_chat(db_path, media_path, output_dir, chat_id, chat_name, is_group): """Generates an HTML file for a single chat session.""" conn = sqlite3.connect(db_path) cursor = conn.cursor() # Updated query to fetch more potential name fields (like ZFIRSTNAME) to find the best one. query = """ SELECT m.ZISFROMME, m.ZTEXT, m.ZMESSAGEDATE, m.ZFROMJID, g.ZCONTACTNAME AS GroupMemberContactName, cs.ZPARTNERNAME AS ChatPartnerName, p.ZPUSHNAME AS ProfilePushName, mi.ZMEDIALOCALPATH FROM ZWAMESSAGE m LEFT JOIN ZWAGROUPMEMBER g ON m.ZGROUPMEMBER = g.Z_PK LEFT JOIN ZWACHATSESSION cs ON m.ZCHATSESSION = cs.Z_PK LEFT JOIN ZWAPROFILEPUSHNAME p ON m.ZFROMJID = p.ZJID LEFT JOIN ZWAMEDIAITEM mi ON m.ZMEDIAITEM = mi.Z_PK WHERE m.ZCHATSESSION = ? ORDER BY m.ZMESSAGEDATE ASC; """ cursor.execute(query, (chat_id,)) messages = cursor.fetchall() conn.close() if not messages: print(f"No messages found for chat: {chat_name}") return # Sanitize chat name for filename, allowing emojis safe_filename = "".join(c for c in chat_name if ( c.isalnum() or c in (' ', '-') or '\U0001F300' <= c <= '\U0001FAFF' # Unicode range for most emojis )).rstrip() chats_dir = os.path.join(output_dir, "chats") os.makedirs(chats_dir, exist_ok=True) html_filename = os.path.join(chats_dir, f"{safe_filename}.html") with open(html_filename, 'w', encoding='utf-8') as f: f.write(f""" Chat with {html.escape(chat_name)}
{html.escape(chat_name)}
""") # Write messages for is_from_me, text, timestamp, from_jid, group_member_contact_name, chat_partner_name, profile_push_name, media_local_path in messages: msg_class = "sent" if is_from_me else "received" f.write(f'
') # Determine and display the sender's name for incoming messages if not is_from_me: # Prioritize group member contact name for group chats if is_group: # Try names in order of preference, avoiding encoded-looking strings potential_names = [ group_member_contact_name, profile_push_name, from_jid, chat_partner_name, ] # Filter out None values and strings that look like they're encoded valid_names = [name for name in potential_names if name and not ( name.startswith('CK') and any(c.isupper() for c in name[2:]) and '=' in name )] sender_name = next((name for name in valid_names), "Unknown") else: # For individual chats, prefer partner name or push name sender_name = chat_partner_name or profile_push_name or from_jid or "Unknown" f.write(f'
{html.escape(str(sender_name))}
') if text: # Replace newline characters with
tags for proper display escaped_text = html.escape(text) f.write(f'
{escaped_text.replace(chr(10), "
")}
') if media_local_path: f.write(get_media_tag(media_local_path, media_path, output_dir)) f.write(f'
{convert_whatsapp_timestamp(timestamp)}
') f.write('
') f.write("""
""") print(f"Successfully generated HTML for: {chat_name}") def main(): parser = argparse.ArgumentParser(description="WhatsApp Chat Exporter") parser.add_argument("db_path", help="Path to the ChatStorage.sqlite file.") parser.add_argument("media_path", help="Path to the root 'Media' directory.") parser.add_argument("--output", default="_html_export", help="Directory to save the HTML files.") args = parser.parse_args() if not os.path.exists(args.db_path): print(f"Error: Database file not found at '{args.db_path}'") return if not os.path.exists(args.media_path): print(f"Error: Media directory not found at '{args.media_path}'") return os.makedirs(args.output, exist_ok=True) conn = sqlite3.connect(args.db_path) cursor = conn.cursor() # Get all chats, joining with ZWAPROFILEPUSHNAME and using COALESCE to get the best possible name. cursor.execute(""" SELECT cs.Z_PK, COALESCE(p.ZPUSHNAME, cs.ZPARTNERNAME) AS ChatName, cs.ZCONTACTJID, cs.ZMESSAGECOUNTER, MIN(m.ZMESSAGEDATE) as FirstMessageDate, MAX(m.ZMESSAGEDATE) as LastMessageDate FROM ZWACHATSESSION cs LEFT JOIN ZWAPROFILEPUSHNAME p ON cs.ZCONTACTJID = p.ZJID LEFT JOIN ZWAMESSAGE m ON cs.Z_PK = m.ZCHATSESSION GROUP BY cs.Z_PK, ChatName, cs.ZCONTACTJID, cs.ZMESSAGECOUNTER ORDER BY LastMessageDate DESC NULLS LAST, ChatName """) chats = cursor.fetchall() conn.close() print(f"Found {len(chats)} chats to export.") index_path = os.path.join(args.output, "whatsapp-chats.html") with open(index_path, 'w', encoding='utf-8') as index_f: index_f.write(f""" WhatsApp Chat Export

WhatsApp Chat Export

Exported on {datetime.now().strftime('%Y-%m-%d %H:%M')}
") # Create a simple redirect index.html redirect_index = os.path.join(args.output, "index.html") with open(redirect_index, 'w', encoding='utf-8') as f: f.write(f""" Redirecting to WhatsApp Chats...

Redirecting to WhatsApp Chats...

""") print(f"\nExport complete!") print(f"View your chats by opening either of these files in your browser:") print(f" • {os.path.abspath(index_path)}") print(f" • {os.path.abspath(redirect_index)}") if __name__ == "__main__": main()