Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.

581 rinda
22 KiB

  1. # WhatsApp Chat Viewer
  2. #
  3. # This script reads a WhatsApp ChatStorage.sqlite database and associated media files
  4. # to generate a browsable HTML archive of chat conversations.
  5. #
  6. # Author: Gemini
  7. # Date: September 7, 2025
  8. # Version: 1.3 - Improved name resolution to avoid displaying encoded strings.
  9. import sqlite3
  10. import os
  11. import argparse
  12. import html
  13. from datetime import datetime, timedelta
  14. import shutil
  15. # WhatsApp's epoch starts on 2001-01-01 00:00:00 (Core Data timestamp)
  16. WHATSAPP_EPOCH = datetime(2001, 1, 1)
  17. def convert_whatsapp_timestamp(ts):
  18. """Converts WhatsApp's Core Data timestamp to a human-readable string."""
  19. if not ts:
  20. return ""
  21. try:
  22. # Timestamps are seconds since the WhatsApp epoch
  23. dt = WHATSAPP_EPOCH + timedelta(seconds=ts)
  24. return dt.strftime('%Y-%m-%d %H:%M:%S')
  25. except (ValueError, TypeError):
  26. return "Invalid date"
  27. def get_media_tag(media_path, output_dir):
  28. """Generates the appropriate HTML tag for a given media file and copies it."""
  29. if not media_path:
  30. return ""
  31. # Path in the DB is often relative like 'Media/WhatsApp Images/IMG-...'
  32. test_path = os.path.join(output_dir, 'Message', media_path)
  33. full_media_path = ''
  34. if not os.path.exists(test_path):
  35. return f'<div class="media-missing">Media not found: {html.escape(test_path)}</div>'
  36. full_media_path = os.path.join('Message', media_path)
  37. # remove ./ in the beginning if present
  38. full_media_path = full_media_path.lstrip('./')
  39. ext = os.path.splitext(media_path)[1].lower()
  40. if ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']:
  41. return f'<img src="../{full_media_path}" loading="lazy" alt="Image" class="media-item">'
  42. elif ext in ['.mp4', '.mov', '.webm']:
  43. return f'<video controls src="../{full_media_path}" class="media-item" loading="lazy"></video>'
  44. elif ext in ['.mp3', '.ogg', '.opus', '.m4a']:
  45. return f'<audio controls src="../{full_media_path}" loading="lazy"></audio>'
  46. else:
  47. return f'<a href="../{full_media_path}" target="_blank">View Media: {os.path.basename(media_path)}</a>'
  48. def generate_html_chat(db_path, media_path, output_dir, chat_id, chat_name, is_group, contact_jid):
  49. """Generates an HTML file for a single chat session."""
  50. conn = sqlite3.connect(db_path)
  51. cursor = conn.cursor()
  52. # Updated query to fetch more potential name fields (like ZFIRSTNAME) to find the best one.
  53. query = """
  54. SELECT
  55. m.ZISFROMME,
  56. m.ZTEXT,
  57. m.ZMESSAGEDATE,
  58. m.ZFROMJID,
  59. g.ZCONTACTNAME AS GroupMemberContactName,
  60. cs.ZPARTNERNAME AS ChatPartnerName,
  61. p.ZPUSHNAME AS ProfilePushName,
  62. mi.ZMEDIALOCALPATH,
  63. cs.ZCONTACTJID AS ChatJID
  64. FROM
  65. ZWAMESSAGE m
  66. LEFT JOIN
  67. ZWAGROUPMEMBER g ON m.ZGROUPMEMBER = g.Z_PK
  68. LEFT JOIN
  69. ZWACHATSESSION cs ON m.ZCHATSESSION = cs.Z_PK
  70. LEFT JOIN
  71. ZWAPROFILEPUSHNAME p ON m.ZFROMJID = p.ZJID
  72. LEFT JOIN
  73. ZWAMEDIAITEM mi ON m.ZMEDIAITEM = mi.Z_PK
  74. WHERE
  75. m.ZCHATSESSION = ?
  76. ORDER BY
  77. m.ZMESSAGEDATE ASC;
  78. """
  79. cursor.execute(query, (chat_id,))
  80. messages = cursor.fetchall()
  81. conn.close()
  82. if not messages:
  83. print(f"No messages found for chat: {chat_name}")
  84. return
  85. # Sanitize contact_jid for a unique and safe filename
  86. if contact_jid:
  87. safe_filename = "".join(c if c.isalnum() else "_" for c in contact_jid)
  88. else:
  89. # Fallback to chat_id if contact_jid is not available
  90. safe_filename = str(chat_id)
  91. chats_dir = os.path.join(output_dir, "chats")
  92. os.makedirs(chats_dir, exist_ok=True)
  93. html_filename = os.path.join(chats_dir, f"{safe_filename}.html")
  94. with open(html_filename, 'w', encoding='utf-8') as f:
  95. f.write(f"""
  96. <!DOCTYPE html>
  97. <html lang="en">
  98. <head>
  99. <meta charset="UTF-8">
  100. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  101. <title>Chat with {html.escape(chat_name)}</title>
  102. <style>
  103. body {{
  104. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  105. background-color: #e5ddd5;
  106. margin: 0;
  107. padding: 20px;
  108. color: #111b21;
  109. min-height: 100vh;
  110. box-sizing: border-box;
  111. }}
  112. .chat-container {{
  113. max-width: 800px;
  114. margin: auto;
  115. background-image: url('../current_wallpaper.jpg');
  116. background-size: auto 100%;
  117. background-attachment: fixed;
  118. background-position: center;
  119. border-radius: 8px;
  120. box-shadow: 0 1px 1px 0 rgba(0,0,0,0.06), 0 2px 5px 0 rgba(0,0,0,0.06);
  121. overflow: hidden;
  122. }}
  123. .chat-header {{
  124. background-color: #008069;
  125. color: white;
  126. padding: 15px 20px;
  127. font-size: 1.2em;
  128. text-align: center;
  129. }}
  130. .chat-header-id {{
  131. font-size: 0.7em;
  132. opacity: 0.8;
  133. margin-top: 5px;
  134. }}
  135. .chat-box {{
  136. padding: 20px;
  137. display: flex;
  138. flex-direction: column;
  139. gap: 12px;
  140. }}
  141. .message {{
  142. padding: 8px 12px;
  143. border-radius: 18px;
  144. max-width: 70%;
  145. word-wrap: break-word;
  146. position: relative;
  147. }}
  148. .message.sent {{
  149. background-color: #dcf8c6;
  150. align-self: flex-end;
  151. border-bottom-right-radius: 4px;
  152. }}
  153. .message.received {{
  154. background-color: #ffffff;
  155. align-self: flex-start;
  156. border-bottom-left-radius: 4px;
  157. }}
  158. .sender-name {{
  159. font-weight: bold;
  160. font-size: 0.9em;
  161. color: #005c4b;
  162. margin-bottom: 4px;
  163. }}
  164. .timestamp {{
  165. font-size: 0.75em;
  166. color: #667781;
  167. margin-top: 5px;
  168. text-align: right;
  169. }}
  170. .media-item {{
  171. max-width: 100%;
  172. border-radius: 8px;
  173. margin-top: 5px;
  174. display: block;
  175. }}
  176. .media-missing {{
  177. font-style: italic;
  178. color: #888;
  179. background-color: #fcebeb;
  180. border: 1px solid #f5c6cb;
  181. padding: 10px;
  182. border-radius: 8px;
  183. }}
  184. </style>
  185. </head>
  186. <body>
  187. <div class="chat-container">
  188. <div class="chat-header">
  189. {html.escape(chat_name)}
  190. <div class="chat-header-id">{contact_jid}</div>
  191. </div>
  192. <div class="chat-box">
  193. """)
  194. # Write messages
  195. for is_from_me, text, timestamp, from_jid, group_member_contact_name, chat_partner_name, profile_push_name, media_local_path, contact_jid in messages:
  196. msg_class = "sent" if is_from_me else "received"
  197. f.write(f'<div class="message {msg_class}">')
  198. # Determine and display the sender's name for incoming messages
  199. if not is_from_me:
  200. # Prioritize group member contact name for group chats
  201. if is_group:
  202. # Try names in order of preference, avoiding encoded-looking strings
  203. potential_names = [
  204. group_member_contact_name,
  205. profile_push_name,
  206. from_jid,
  207. chat_partner_name,
  208. ]
  209. # Filter out None values and strings that look like they're encoded
  210. valid_names = [name for name in potential_names if name and not (
  211. name.startswith('CK') and any(c.isupper() for c in name[2:]) and '=' in name
  212. )]
  213. sender_name = next((name for name in valid_names), "Unknown")
  214. else:
  215. # For individual chats, prefer partner name or push name
  216. sender_name = chat_partner_name or profile_push_name or from_jid or "Unknown"
  217. f.write(f'<div class="sender-name">{html.escape(str(sender_name))}</div>')
  218. if text:
  219. # Replace newline characters with <br> tags for proper display
  220. escaped_text = html.escape(text)
  221. f.write(f'<div>{escaped_text.replace(chr(10), "<br>")}</div>')
  222. if media_local_path:
  223. # print("Media path:", media_local_path)
  224. f.write(get_media_tag(media_local_path, output_dir))
  225. f.write(f'<div class="timestamp">{convert_whatsapp_timestamp(timestamp)}</div>')
  226. f.write('</div>')
  227. f.write("""
  228. </div>
  229. </div>
  230. </body>
  231. </html>
  232. """)
  233. print(f"Successfully generated HTML for: {chat_name}")
  234. # Step: iPhone backup manifest.db processing
  235. def process_iphone_backup(backup_path, output_dir):
  236. """
  237. Processes the iPhone backup manifest.db, extracts WhatsApp shared files, and recreates the file structure in output_dir.
  238. """
  239. manifest_db_path = os.path.join(backup_path, 'Manifest.db')
  240. if not os.path.exists(manifest_db_path):
  241. print(f"Manifest.db not found in backup path: {manifest_db_path}")
  242. return
  243. # Connect to manifest.db and extract WhatsApp shared files
  244. conn = sqlite3.connect(manifest_db_path)
  245. cursor = conn.cursor()
  246. cursor.execute("SELECT fileID, domain, relativePath FROM Files WHERE domain = ?", ('AppDomainGroup-group.net.whatsapp.WhatsApp.shared',))
  247. files = cursor.fetchall()
  248. print(f"Found {len(files)} WhatsApp shared files in manifest.db.")
  249. # Prepare to recreate file structure
  250. for fileID, domain, relativePath in files:
  251. src_file = os.path.join(backup_path, fileID[:2], fileID)
  252. dest_file = os.path.join(output_dir, relativePath)
  253. os.makedirs(os.path.dirname(dest_file), exist_ok=True)
  254. if os.path.exists(src_file):
  255. if not os.path.exists(dest_file):
  256. try:
  257. shutil.copy2(src_file, dest_file)
  258. except Exception as e:
  259. print(f"Error copying {src_file} to {dest_file}: {e}")
  260. else:
  261. print(f"Source file missing: {src_file}")
  262. def main():
  263. parser = argparse.ArgumentParser(description="WhatsApp Chat Exporter")
  264. parser.add_argument("--output", default="_html_export", help="Directory to save the HTML files.")
  265. parser.add_argument("--backup-path", default=None, help="Path to iPhone backup directory (for manifest.db processing)")
  266. args = parser.parse_args()
  267. if args.backup_path:
  268. process_iphone_backup(args.backup_path, args.output)
  269. # Use backup paths for archive creation
  270. db_path = os.path.join(args.output, "ChatStorage.sqlite")
  271. media_path = os.path.join(args.output, "Message/")
  272. else:
  273. parser.add_argument("db_path", help="Path to the ChatStorage.sqlite file.")
  274. parser.add_argument("media_path", help="Path to the root 'Media' directory.")
  275. args = parser.parse_args()
  276. db_path = args.db_path
  277. media_path = args.media_path
  278. if not os.path.exists(db_path):
  279. print(f"Error: Database file not found at '{db_path}'")
  280. return
  281. if not os.path.exists(media_path):
  282. print(f"Error: Media directory not found at '{media_path}'")
  283. return
  284. os.makedirs(args.output, exist_ok=True)
  285. conn = sqlite3.connect(db_path)
  286. cursor = conn.cursor()
  287. # Get all chats, joining with ZWAPROFILEPUSHNAME and using COALESCE to get the best possible name.
  288. cursor.execute("""
  289. SELECT
  290. cs.Z_PK,
  291. COALESCE(p.ZPUSHNAME, cs.ZPARTNERNAME) AS ChatName,
  292. cs.ZCONTACTJID,
  293. cs.ZMESSAGECOUNTER,
  294. MIN(m.ZMESSAGEDATE) as FirstMessageDate,
  295. MAX(m.ZMESSAGEDATE) as LastMessageDate,
  296. COALESCE(gi.ZPICTUREPATH, pic.ZPATH) AS AvatarPath
  297. FROM
  298. ZWACHATSESSION cs
  299. LEFT JOIN
  300. ZWAPROFILEPUSHNAME p ON cs.ZCONTACTJID = p.ZJID
  301. LEFT JOIN
  302. ZWAMESSAGE m ON cs.Z_PK = m.ZCHATSESSION
  303. LEFT JOIN
  304. ZWAGROUPINFO gi ON cs.ZGROUPINFO = gi.Z_PK
  305. LEFT JOIN
  306. ZWAPROFILEPICTUREITEM pic ON cs.ZCONTACTJID = pic.ZJID
  307. WHERE
  308. cs.ZCONTACTJID NOT LIKE '%@status'
  309. GROUP BY
  310. cs.Z_PK, ChatName, cs.ZCONTACTJID, cs.ZMESSAGECOUNTER, AvatarPath
  311. ORDER BY
  312. LastMessageDate DESC NULLS LAST, ChatName
  313. """)
  314. chats = cursor.fetchall()
  315. conn.close()
  316. print(f"Found {len(chats)} chats to export.")
  317. index_path = os.path.join(args.output, "whatsapp-chats.html")
  318. with open(index_path, 'w', encoding='utf-8') as index_f:
  319. index_f.write(f"""
  320. <!DOCTYPE html>
  321. <html lang="en">
  322. <head>
  323. <meta charset="UTF-8">
  324. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  325. <title>WhatsApp Chat Export</title>
  326. <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path fill='%23128C7E' d='M12 2C6.5 2 2 6.5 2 12c0 2 .6 3.9 1.6 5.4L2 22l4.6-1.6c1.5 1 3.4 1.6 5.4 1.6 5.5 0 10-4.5 10-10S17.5 2 12 2z'/></svg>">
  327. <style>
  328. body {{
  329. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  330. background-color: #f4f4f9;
  331. margin: 0;
  332. padding: 20px;
  333. min-height: 100vh;
  334. }}
  335. .header {{
  336. background-color: #128C7E;
  337. color: white;
  338. padding: 20px;
  339. margin: -20px -20px 20px -20px;
  340. text-align: center;
  341. box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  342. }}
  343. .header h1 {{
  344. margin: 0;
  345. font-size: 1.8em;
  346. }}
  347. .export-info {{
  348. color: rgba(255,255,255,0.9);
  349. margin-top: 8px;
  350. font-size: 0.9em;
  351. }}
  352. .container {{
  353. max-width: 700px;
  354. margin: auto;
  355. background: white;
  356. padding: 20px;
  357. border-radius: 12px;
  358. box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  359. }}
  360. ul {{ list-style-type: none; padding: 0; }}
  361. li {{ margin: 8px 0; }}
  362. .chat-entry {{
  363. text-decoration: none;
  364. color: #0056b3;
  365. background-color: #fff;
  366. padding: 12px;
  367. border-radius: 8px;
  368. display: flex;
  369. align-items: center;
  370. box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  371. transition: all 0.2s ease-in-out;
  372. gap: 12px;
  373. }}
  374. a.chat-entry:hover {{
  375. background-color: #e9ecef;
  376. transform: translateY(-2px);
  377. box-shadow: 0 4px 8px rgba(0,0,0,0.15);
  378. }}
  379. .chat-entry.inactive {{
  380. color: #999;
  381. background-color: #f8f9fa;
  382. cursor: default;
  383. }}
  384. .chat-avatar {{
  385. width: 48px;
  386. height: 48px;
  387. border-radius: 50%;
  388. background-size: cover;
  389. background-position: center;
  390. flex-shrink: 0;
  391. }}
  392. .chat-avatar.default-individual {{
  393. background-color: #DFE5E7;
  394. background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23999"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>');
  395. }}
  396. .chat-avatar.default-group {{
  397. background-color: #DFE5E7;
  398. background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23999"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>');
  399. }}
  400. .chat-info {{
  401. flex-grow: 1;
  402. min-width: 0;
  403. }}
  404. .message-count {{
  405. background-color: #128C7E;
  406. color: white;
  407. padding: 4px 8px;
  408. border-radius: 12px;
  409. font-size: 0.85em;
  410. min-width: 24px;
  411. text-align: center;
  412. }}
  413. .message-count.zero {{
  414. background-color: #ddd;
  415. }}
  416. .chat-info {{
  417. display: flex;
  418. flex-direction: column;
  419. gap: 4px;
  420. }}
  421. .chat-name {{
  422. font-weight: 500;
  423. }}
  424. .date-range {{
  425. font-size: 0.8em;
  426. color: #667781;
  427. }}
  428. .chat-entry.inactive .date-range {{
  429. color: #999;
  430. }}
  431. </style>
  432. </head>
  433. <body>
  434. <div class="header">
  435. <h1>WhatsApp Chat Export</h1>
  436. <div class="export-info">Exported on {datetime.now().strftime('%Y-%m-%d %H:%M')}</div>
  437. </div>
  438. <div class="container">
  439. <ul>
  440. """)
  441. for chat_id, chat_name, contact_jid, message_count, first_message_date, last_message_date, avatar_path in chats:
  442. if not chat_name:
  443. chat_name = f"Unknown Chat ({contact_jid or chat_id})"
  444. full_avatar_path = avatar_path if avatar_path and os.path.isabs(avatar_path) else os.path.join(args.output, avatar_path) if avatar_path else None
  445. # Find all file paths in args.output that start with full_avatar_path
  446. matching_files = []
  447. if full_avatar_path:
  448. for root, dirs, files in os.walk(args.output):
  449. for file in files:
  450. file_path = os.path.join(root, file)
  451. if file_path.startswith(full_avatar_path):
  452. matching_files.append(file_path)
  453. # Use the first matching file if available
  454. if matching_files:
  455. avatar_path = os.path.relpath(matching_files[0], args.output)
  456. full_avatar_path = matching_files[0]
  457. # A group chat JID typically ends with '@g.us'
  458. is_group = contact_jid and '@g.us' in contact_jid
  459. # Sanitize contact_jid for a unique and safe filename
  460. if contact_jid:
  461. safe_filename = "".join(c if c.isalnum() else "_" for c in contact_jid)
  462. else:
  463. # Fallback to chat_id if contact_jid is not available
  464. safe_filename = str(chat_id)
  465. # Add default avatar based on chat type
  466. if avatar_path and os.path.exists(full_avatar_path):
  467. avatar_html = f'<div class="chat-avatar" style="background-image: url(\'{avatar_path}\');"></div>'
  468. else:
  469. avatar_html = f'<div class="chat-avatar default-{"group" if is_group else "individual"}"></div>'
  470. # Format date range
  471. date_range = ""
  472. if message_count > 0 and first_message_date and last_message_date:
  473. first_date = convert_whatsapp_timestamp(first_message_date).split()[0] # Get just the date part
  474. last_date = convert_whatsapp_timestamp(last_message_date).split()[0]
  475. if first_date == last_date:
  476. date_range = first_date
  477. else:
  478. date_range = f"{first_date} – {last_date}"
  479. if message_count > 0:
  480. # Generate chat HTML only for chats with messages
  481. generate_html_chat(db_path, media_path, args.output, chat_id, chat_name, is_group, contact_jid)
  482. # Clickable entry with link
  483. index_f.write(
  484. f'<li><a class="chat-entry" href="chats/{html.escape(safe_filename)}.html">'
  485. f'{avatar_html}'
  486. f'<div class="chat-info">'
  487. f'<span class="chat-name">{html.escape(str(chat_name))}</span>'
  488. f'<span class="date-range">{date_range}</span>'
  489. f'</div>'
  490. f'<span class="message-count">{message_count:,}</span>'
  491. f'</a></li>'
  492. )
  493. else:
  494. # Non-clickable entry for empty chats
  495. index_f.write(
  496. f'<li><div class="chat-entry inactive">'
  497. f'{avatar_html}'
  498. f'<div class="chat-info">'
  499. f'<span class="chat-name">{html.escape(str(chat_name))}</span>'
  500. f'<span class="date-range">No messages</span>'
  501. f'</div>'
  502. f'<span class="message-count zero">0</span>'
  503. f'</div></li>'
  504. )
  505. index_f.write("</ul></div></body></html>")
  506. # Create a simple redirect index.html
  507. redirect_index = os.path.join(args.output, "index.html")
  508. with open(redirect_index, 'w', encoding='utf-8') as f:
  509. f.write(f"""<!DOCTYPE html>
  510. <html>
  511. <head>
  512. <meta http-equiv="refresh" content="0; url=whatsapp-chats.html">
  513. <title>Redirecting to WhatsApp Chats...</title>
  514. </head>
  515. <body>
  516. <p>Redirecting to <a href="whatsapp-chats.html">WhatsApp Chats</a>...</p>
  517. </body>
  518. </html>""")
  519. print(f"\nExport complete!")
  520. print(f"View your chats by opening either of these files in your browser:")
  521. print(f" • {os.path.abspath(index_path)}")
  522. print(f" • {os.path.abspath(redirect_index)}")
  523. if __name__ == "__main__":
  524. main()