Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

509 Zeilen
19 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 representation of your chats.
  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, media_root_dir, 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. full_media_path = os.path.join(media_root_dir, os.path.basename(media_path))
  33. # Sometimes the path is nested inside a subdirectory within the main Media folder
  34. if not os.path.exists(full_media_path):
  35. full_media_path = os.path.join(media_root_dir, media_path)
  36. if not os.path.exists(full_media_path):
  37. return f'<div class="media-missing">Media not found: {html.escape(media_path)}</div>'
  38. # Create a unique-ish path to avoid filename collisions
  39. relative_media_path = os.path.join('media', os.path.basename(media_path))
  40. dest_path = os.path.join(output_dir, relative_media_path)
  41. os.makedirs(os.path.dirname(dest_path), exist_ok=True)
  42. if not os.path.exists(dest_path):
  43. try:
  44. shutil.copy(full_media_path, dest_path)
  45. except Exception as e:
  46. return f'<div class="media-missing">Error copying media: {html.escape(str(e))}</div>'
  47. ext = os.path.splitext(media_path)[1].lower()
  48. if ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']:
  49. return f'<img src="{relative_media_path}" alt="Image" class="media-item">'
  50. elif ext in ['.mp4', '.mov', '.webm']:
  51. return f'<video controls src="{relative_media_path}" class="media-item"></video>'
  52. elif ext in ['.mp3', '.ogg', '.opus', '.m4a']:
  53. return f'<audio controls src="{relative_media_path}"></audio>'
  54. else:
  55. return f'<a href="{relative_media_path}" target="_blank">View Media: {os.path.basename(media_path)}</a>'
  56. def generate_html_chat(db_path, media_path, output_dir, chat_id, chat_name, is_group):
  57. """Generates an HTML file for a single chat session."""
  58. conn = sqlite3.connect(db_path)
  59. cursor = conn.cursor()
  60. # Updated query to fetch more potential name fields (like ZFIRSTNAME) to find the best one.
  61. query = """
  62. SELECT
  63. m.ZISFROMME,
  64. m.ZTEXT,
  65. m.ZMESSAGEDATE,
  66. m.ZFROMJID,
  67. g.ZCONTACTNAME AS GroupMemberContactName,
  68. cs.ZPARTNERNAME AS ChatPartnerName,
  69. p.ZPUSHNAME AS ProfilePushName,
  70. mi.ZMEDIALOCALPATH
  71. FROM
  72. ZWAMESSAGE m
  73. LEFT JOIN
  74. ZWAGROUPMEMBER g ON m.ZGROUPMEMBER = g.Z_PK
  75. LEFT JOIN
  76. ZWACHATSESSION cs ON m.ZCHATSESSION = cs.Z_PK
  77. LEFT JOIN
  78. ZWAPROFILEPUSHNAME p ON m.ZFROMJID = p.ZJID
  79. LEFT JOIN
  80. ZWAMEDIAITEM mi ON m.ZMEDIAITEM = mi.Z_PK
  81. WHERE
  82. m.ZCHATSESSION = ?
  83. ORDER BY
  84. m.ZMESSAGEDATE ASC;
  85. """
  86. cursor.execute(query, (chat_id,))
  87. messages = cursor.fetchall()
  88. conn.close()
  89. if not messages:
  90. print(f"No messages found for chat: {chat_name}")
  91. return
  92. # Sanitize chat name for filename, allowing emojis
  93. safe_filename = "".join(c for c in chat_name if (
  94. c.isalnum() or
  95. c in (' ', '-') or
  96. '\U0001F300' <= c <= '\U0001FAFF' # Unicode range for most emojis
  97. )).rstrip()
  98. chats_dir = os.path.join(output_dir, "chats")
  99. os.makedirs(chats_dir, exist_ok=True)
  100. html_filename = os.path.join(chats_dir, f"{safe_filename}.html")
  101. with open(html_filename, 'w', encoding='utf-8') as f:
  102. f.write(f"""
  103. <!DOCTYPE html>
  104. <html lang="en">
  105. <head>
  106. <meta charset="UTF-8">
  107. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  108. <title>Chat with {html.escape(chat_name)}</title>
  109. <style>
  110. body {{
  111. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  112. background-color: #e5ddd5;
  113. margin: 0;
  114. padding: 20px;
  115. color: #111b21;
  116. }}
  117. .chat-container {{
  118. max-width: 800px;
  119. margin: auto;
  120. background-image: url('https://user-images.githubusercontent.com/15075759/28719144-86dc0f70-73b1-11e7-911d-60d70fcded21.png'); /* Subtle background pattern */
  121. border-radius: 8px;
  122. box-shadow: 0 1px 1px 0 rgba(0,0,0,0.06), 0 2px 5px 0 rgba(0,0,0,0.06);
  123. overflow: hidden;
  124. }}
  125. .chat-header {{
  126. background-color: #008069;
  127. color: white;
  128. padding: 15px 20px;
  129. font-size: 1.2em;
  130. text-align: center;
  131. }}
  132. .chat-box {{
  133. padding: 20px;
  134. display: flex;
  135. flex-direction: column;
  136. gap: 12px;
  137. }}
  138. .message {{
  139. padding: 8px 12px;
  140. border-radius: 18px;
  141. max-width: 70%;
  142. word-wrap: break-word;
  143. position: relative;
  144. }}
  145. .message.sent {{
  146. background-color: #dcf8c6;
  147. align-self: flex-end;
  148. border-bottom-right-radius: 4px;
  149. }}
  150. .message.received {{
  151. background-color: #ffffff;
  152. align-self: flex-start;
  153. border-bottom-left-radius: 4px;
  154. }}
  155. .sender-name {{
  156. font-weight: bold;
  157. font-size: 0.9em;
  158. color: #005c4b;
  159. margin-bottom: 4px;
  160. }}
  161. .timestamp {{
  162. font-size: 0.75em;
  163. color: #667781;
  164. margin-top: 5px;
  165. text-align: right;
  166. }}
  167. .media-item {{
  168. max-width: 100%;
  169. border-radius: 8px;
  170. margin-top: 5px;
  171. display: block;
  172. }}
  173. .media-missing {{
  174. font-style: italic;
  175. color: #888;
  176. background-color: #fcebeb;
  177. border: 1px solid #f5c6cb;
  178. padding: 10px;
  179. border-radius: 8px;
  180. }}
  181. </style>
  182. </head>
  183. <body>
  184. <div class="chat-container">
  185. <div class="chat-header">{html.escape(chat_name)}</div>
  186. <div class="chat-box">
  187. """)
  188. # Write messages
  189. for is_from_me, text, timestamp, from_jid, group_member_contact_name, chat_partner_name, profile_push_name, media_local_path in messages:
  190. msg_class = "sent" if is_from_me else "received"
  191. f.write(f'<div class="message {msg_class}">')
  192. # Determine and display the sender's name for incoming messages
  193. if not is_from_me:
  194. # Prioritize group member contact name for group chats
  195. if is_group:
  196. # Try names in order of preference, avoiding encoded-looking strings
  197. potential_names = [
  198. group_member_contact_name,
  199. profile_push_name,
  200. from_jid,
  201. chat_partner_name,
  202. ]
  203. # Filter out None values and strings that look like they're encoded
  204. valid_names = [name for name in potential_names if name and not (
  205. name.startswith('CK') and any(c.isupper() for c in name[2:]) and '=' in name
  206. )]
  207. sender_name = next((name for name in valid_names), "Unknown")
  208. else:
  209. # For individual chats, prefer partner name or push name
  210. sender_name = chat_partner_name or profile_push_name or from_jid or "Unknown"
  211. f.write(f'<div class="sender-name">{html.escape(str(sender_name))}</div>')
  212. if text:
  213. # Replace newline characters with <br> tags for proper display
  214. escaped_text = html.escape(text)
  215. f.write(f'<div>{escaped_text.replace(chr(10), "<br>")}</div>')
  216. if media_local_path:
  217. f.write(get_media_tag(media_local_path, media_path, output_dir))
  218. f.write(f'<div class="timestamp">{convert_whatsapp_timestamp(timestamp)}</div>')
  219. f.write('</div>')
  220. f.write("""
  221. </div>
  222. </div>
  223. </body>
  224. </html>
  225. """)
  226. print(f"Successfully generated HTML for: {chat_name}")
  227. def main():
  228. parser = argparse.ArgumentParser(description="WhatsApp Chat Exporter")
  229. parser.add_argument("db_path", help="Path to the ChatStorage.sqlite file.")
  230. parser.add_argument("media_path", help="Path to the root 'Media' directory.")
  231. parser.add_argument("--output", default="_html_export", help="Directory to save the HTML files.")
  232. args = parser.parse_args()
  233. if not os.path.exists(args.db_path):
  234. print(f"Error: Database file not found at '{args.db_path}'")
  235. return
  236. if not os.path.exists(args.media_path):
  237. print(f"Error: Media directory not found at '{args.media_path}'")
  238. return
  239. os.makedirs(args.output, exist_ok=True)
  240. conn = sqlite3.connect(args.db_path)
  241. cursor = conn.cursor()
  242. # Get all chats, joining with ZWAPROFILEPUSHNAME and using COALESCE to get the best possible name.
  243. cursor.execute("""
  244. SELECT
  245. cs.Z_PK,
  246. COALESCE(p.ZPUSHNAME, cs.ZPARTNERNAME) AS ChatName,
  247. cs.ZCONTACTJID,
  248. cs.ZMESSAGECOUNTER,
  249. MIN(m.ZMESSAGEDATE) as FirstMessageDate,
  250. MAX(m.ZMESSAGEDATE) as LastMessageDate
  251. FROM
  252. ZWACHATSESSION cs
  253. LEFT JOIN
  254. ZWAPROFILEPUSHNAME p ON cs.ZCONTACTJID = p.ZJID
  255. LEFT JOIN
  256. ZWAMESSAGE m ON cs.Z_PK = m.ZCHATSESSION
  257. GROUP BY
  258. cs.Z_PK, ChatName, cs.ZCONTACTJID, cs.ZMESSAGECOUNTER
  259. ORDER BY
  260. LastMessageDate DESC NULLS LAST, ChatName
  261. """)
  262. chats = cursor.fetchall()
  263. conn.close()
  264. print(f"Found {len(chats)} chats to export.")
  265. index_path = os.path.join(args.output, "whatsapp-chats.html")
  266. with open(index_path, 'w', encoding='utf-8') as index_f:
  267. index_f.write(f"""
  268. <!DOCTYPE html>
  269. <html lang="en">
  270. <head>
  271. <meta charset="UTF-8">
  272. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  273. <title>WhatsApp Chat Export</title>
  274. <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>">
  275. <style>
  276. body {{
  277. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  278. background-color: #f4f4f9;
  279. margin: 0;
  280. padding: 20px;
  281. min-height: 100vh;
  282. }}
  283. .header {{
  284. background-color: #128C7E;
  285. color: white;
  286. padding: 20px;
  287. margin: -20px -20px 20px -20px;
  288. text-align: center;
  289. box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  290. }}
  291. .header h1 {{
  292. margin: 0;
  293. font-size: 1.8em;
  294. }}
  295. .export-info {{
  296. color: rgba(255,255,255,0.9);
  297. margin-top: 8px;
  298. font-size: 0.9em;
  299. }}
  300. .container {{
  301. max-width: 700px;
  302. margin: auto;
  303. background: white;
  304. padding: 20px;
  305. border-radius: 12px;
  306. box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  307. }}
  308. ul {{ list-style-type: none; padding: 0; }}
  309. li {{ margin: 8px 0; }}
  310. .chat-entry {{
  311. text-decoration: none;
  312. color: #0056b3;
  313. background-color: #fff;
  314. padding: 12px;
  315. border-radius: 8px;
  316. display: flex;
  317. align-items: center;
  318. box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  319. transition: all 0.2s ease-in-out;
  320. gap: 12px;
  321. }}
  322. a.chat-entry:hover {{
  323. background-color: #e9ecef;
  324. transform: translateY(-2px);
  325. box-shadow: 0 4px 8px rgba(0,0,0,0.15);
  326. }}
  327. .chat-entry.inactive {{
  328. color: #999;
  329. background-color: #f8f9fa;
  330. cursor: default;
  331. }}
  332. .chat-avatar {{
  333. width: 48px;
  334. height: 48px;
  335. border-radius: 50%;
  336. background-size: cover;
  337. background-position: center;
  338. flex-shrink: 0;
  339. }}
  340. .chat-avatar.default-individual {{
  341. background-color: #DFE5E7;
  342. 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>');
  343. }}
  344. .chat-avatar.default-group {{
  345. background-color: #DFE5E7;
  346. 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>');
  347. }}
  348. .chat-info {{
  349. flex-grow: 1;
  350. min-width: 0;
  351. }}
  352. .message-count {{
  353. background-color: #128C7E;
  354. color: white;
  355. padding: 4px 8px;
  356. border-radius: 12px;
  357. font-size: 0.85em;
  358. min-width: 24px;
  359. text-align: center;
  360. }}
  361. .message-count.zero {{
  362. background-color: #ddd;
  363. }}
  364. .chat-info {{
  365. display: flex;
  366. flex-direction: column;
  367. gap: 4px;
  368. }}
  369. .chat-name {{
  370. font-weight: 500;
  371. }}
  372. .date-range {{
  373. font-size: 0.8em;
  374. color: #667781;
  375. }}
  376. .chat-entry.inactive .date-range {{
  377. color: #999;
  378. }}
  379. </style>
  380. </head>
  381. <body>
  382. <div class="header">
  383. <h1>WhatsApp Chat Export</h1>
  384. <div class="export-info">Exported on {datetime.now().strftime('%Y-%m-%d %H:%M')}</div>
  385. </div>
  386. <div class="container">
  387. <ul>
  388. """)
  389. for chat_id, chat_name, contact_jid, message_count, first_message_date, last_message_date in chats:
  390. if not chat_name:
  391. chat_name = f"Unknown Chat ({contact_jid or chat_id})"
  392. # A group chat JID typically ends with '@g.us'
  393. is_group = contact_jid and '@g.us' in contact_jid
  394. # Allow alphanumeric, spaces, hyphens, and emojis in filename
  395. safe_filename = "".join(c for c in chat_name if (
  396. c.isalnum() or
  397. c in (' ', '-') or
  398. '\U0001F300' <= c <= '\U0001FAFF' # Unicode range for most emojis
  399. )).rstrip()
  400. # Add default avatar based on chat type
  401. avatar_html = f'<div class="chat-avatar default-{"group" if is_group else "individual"}"></div>'
  402. # Format date range
  403. date_range = ""
  404. if message_count > 0 and first_message_date and last_message_date:
  405. first_date = convert_whatsapp_timestamp(first_message_date).split()[0] # Get just the date part
  406. last_date = convert_whatsapp_timestamp(last_message_date).split()[0]
  407. if first_date == last_date:
  408. date_range = first_date
  409. else:
  410. date_range = f"{first_date} – {last_date}"
  411. if message_count > 0:
  412. # Generate chat HTML only for chats with messages
  413. generate_html_chat(args.db_path, args.media_path, args.output, chat_id, chat_name, is_group)
  414. # Clickable entry with link
  415. index_f.write(
  416. f'<li><a class="chat-entry" href="chats/{html.escape(safe_filename)}.html">'
  417. f'{avatar_html}'
  418. f'<div class="chat-info">'
  419. f'<span class="chat-name">{html.escape(str(chat_name))}</span>'
  420. f'<span class="date-range">{date_range}</span>'
  421. f'</div>'
  422. f'<span class="message-count">{message_count:,}</span>'
  423. f'</a></li>'
  424. )
  425. else:
  426. # Non-clickable entry for empty chats
  427. index_f.write(
  428. f'<li><div class="chat-entry inactive">'
  429. f'{avatar_html}'
  430. f'<div class="chat-info">'
  431. f'<span class="chat-name">{html.escape(str(chat_name))}</span>'
  432. f'<span class="date-range">No messages</span>'
  433. f'</div>'
  434. f'<span class="message-count zero">0</span>'
  435. f'</div></li>'
  436. )
  437. index_f.write("</ul></div></body></html>")
  438. # Create a simple redirect index.html
  439. redirect_index = os.path.join(args.output, "index.html")
  440. with open(redirect_index, 'w', encoding='utf-8') as f:
  441. f.write(f"""<!DOCTYPE html>
  442. <html>
  443. <head>
  444. <meta http-equiv="refresh" content="0; url=whatsapp-chats.html">
  445. <title>Redirecting to WhatsApp Chats...</title>
  446. </head>
  447. <body>
  448. <p>Redirecting to <a href="whatsapp-chats.html">WhatsApp Chats</a>...</p>
  449. </body>
  450. </html>""")
  451. print(f"\nExport complete!")
  452. print(f"View your chats by opening either of these files in your browser:")
  453. print(f" • {os.path.abspath(index_path)}")
  454. print(f" • {os.path.abspath(redirect_index)}")
  455. if __name__ == "__main__":
  456. main()